Compare commits
7 Commits
b9a4e75da9
...
e30e9d4f14
| Author | SHA1 | Date |
|---|---|---|
|
|
e30e9d4f14 | |
|
|
9c0910f30f | |
|
|
93b6ad248a | |
|
|
76568672d7 | |
|
|
e655d8b4f5 | |
|
|
343955241d | |
|
|
a1601009dc |
|
|
@ -5,3 +5,6 @@ __pycache__/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
*.b64
|
||||||
|
downloads/
|
||||||
|
migration-output/
|
||||||
|
|
|
||||||
130
README.md
130
README.md
|
|
@ -1,30 +1,108 @@
|
||||||
# adobe-to-docusign-migrator
|
# Adobe Sign → DocuSign Migrator
|
||||||
|
|
||||||
## Project Purpose
|
A Python toolkit for migrating library templates from Adobe Sign (Acrobat Sign) to DocuSign.
|
||||||
A migration toolkit/agent that automates extraction of Adobe Sign library templates (PDFs, fields, roles, workflow), transforms them to DocuSign template model, and creates/imports them into DocuSign.
|
It downloads templates via the Adobe Sign API, converts them to DocuSign format, and uploads them via the DocuSign API.
|
||||||
|
|
||||||
## Background
|
|
||||||
- Adobe Sign and DocuSign both expose APIs to manage templates, fields, recipients, logic, and documents.
|
|
||||||
- Adobe Sign (Acrobat Sign) uses "library documents" as templates, with data accessible via JSON API calls (but not exportable in a single file). You assemble the template info by calling:
|
|
||||||
- `/libraryDocuments/{libraryDocumentId}` (metadata, PDFs, roles)
|
|
||||||
- `/libraryDocuments/{libraryDocumentId}/formFields` (fields/tags)
|
|
||||||
- `/libraryDocuments/{libraryDocumentId}/recipients` (recipients)
|
|
||||||
- `/libraryDocuments/{libraryDocumentId}/workflows` (if applicable)
|
|
||||||
- `/libraryDocuments/{libraryDocumentId}/auditTrail` (audit log, rarely needed for migration)
|
|
||||||
- DocuSign templates are more easily exported as a single payload, but you can also build them incrementally over the API.
|
|
||||||
- The most complex part of migration is mapping logic/fields/roles that are not 1:1 matches (conditional fields, complex routing).
|
|
||||||
|
|
||||||
## API Output Samples
|
|
||||||
See `api-samples.md` for category-by-category JSON breakdowns.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
- Add full agent harness scaffold (`docs/agent-harness/` structure, project README, spec templates)
|
|
||||||
- Collect sample real-world Adobe Sign template JSONs
|
|
||||||
- Define mapping/transforms for fields, roles, logic
|
|
||||||
- Write initial extraction + mapping scripts
|
|
||||||
- Draft Product Spec
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Created: 2026-04-14*
|
## What it does
|
||||||
*scaffolded by Cleo*
|
|
||||||
|
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
|
||||||
|
2. **Downloads** all visible library templates — PDF, metadata, and form field definitions
|
||||||
|
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, and recipient roles
|
||||||
|
4. **Uploads** the converted template to DocuSign
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 18+ (for the DocuSign upload CLI — uses the `esign-direct` package)
|
||||||
|
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
|
||||||
|
- A DocuSign developer account with an integration key and RSA keypair
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
**1. Install Python dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create a `.env` file** in the project root (never commit this):
|
||||||
|
```
|
||||||
|
ADOBE_CLIENT_ID=your-adobe-client-id
|
||||||
|
ADOBE_CLIENT_SECRET=your-adobe-client-secret
|
||||||
|
ADOBE_REDIRECT_URI=https://localhost:8080/callback
|
||||||
|
ADOBE_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6/
|
||||||
|
|
||||||
|
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
|
||||||
|
DOCUSIGN_INTEGRATION_KEY=your-integration-key
|
||||||
|
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
||||||
|
DOCUSIGN_PRIVATE_KEY_PATH=/path/to/private.key
|
||||||
|
DOCUSIGN_USER_ID=your-docusign-user-id
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Authenticate with Adobe Sign** (one-time):
|
||||||
|
```bash
|
||||||
|
python3 src/auth_adobe.py
|
||||||
|
```
|
||||||
|
This opens a browser. After authorizing, paste the redirect URL back into the terminal. Tokens are saved to `.env` and auto-refreshed on subsequent runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running a migration
|
||||||
|
|
||||||
|
**Download all templates from Adobe Sign:**
|
||||||
|
```bash
|
||||||
|
python3 src/download_templates.py
|
||||||
|
```
|
||||||
|
Downloads to `downloads/<template-name>__<id>/` — one folder per template containing `metadata.json`, `form_fields.json`, `documents.json`, and the PDF.
|
||||||
|
|
||||||
|
**Convert a downloaded template to DocuSign format:**
|
||||||
|
```bash
|
||||||
|
python3 src/compose_docusign_template.py
|
||||||
|
```
|
||||||
|
Writes DocuSign template JSONs to `migration-output/<template-name>/docusign-template.json`.
|
||||||
|
|
||||||
|
**Upload to DocuSign:**
|
||||||
|
```bash
|
||||||
|
node packages/esign-direct/build/cli.js templates create --file migration-output/<name>/docusign-template.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or run the full pipeline end-to-end for a specific template:**
|
||||||
|
```bash
|
||||||
|
python3 src/migrate_paul_template.py
|
||||||
|
```
|
||||||
|
(Edit the `TEMPLATE_NAME` constant at the top of the script to target a different template. If multiple templates share the same name, the most recently modified one is used.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field type mapping
|
||||||
|
|
||||||
|
See [field-mapping.md](field-mapping.md) for the full Adobe Sign → DocuSign tab type table, including edge cases and known gaps.
|
||||||
|
|
||||||
|
## Known API quirks and bugs
|
||||||
|
|
||||||
|
See [tests/PLATFORM-QUIRKS.md](tests/PLATFORM-QUIRKS.md) for documented platform bugs, unexpected API behaviors, and the fixes applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
auth_adobe.py # One-time OAuth flow for Adobe Sign
|
||||||
|
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||||
|
download_templates.py # Download all templates from Adobe Sign
|
||||||
|
compose_docusign_template.py # Core conversion logic: Adobe → DocuSign JSON
|
||||||
|
migrate_paul_template.py # End-to-end runner (download → convert → upload)
|
||||||
|
|
||||||
|
downloads/ # Downloaded Adobe Sign templates (gitignored)
|
||||||
|
migration-output/ # Converted DocuSign template JSONs (gitignored)
|
||||||
|
|
||||||
|
field-mapping.md # Field type mapping table + edge case log
|
||||||
|
tests/PLATFORM-QUIRKS.md # Known bugs and API quirks
|
||||||
|
requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Architecture & Design Overview
|
||||||
|
|
||||||
|
## System Components
|
||||||
|
- **Extraction Layer**: Handles authentication, API calls, and raw data retrieval from Adobe Sign. Input: .env credentials. Output: JSON metadata + field data.
|
||||||
|
- **Mapping/Transform Layer**: Pure logic between raw Adobe template objects and canonical DocuSign template model. Handles all 1:1, many:1, and lossy mappings. Logging of ambiguities.
|
||||||
|
- **DocuSign Ingest Layer**: Authenticates, creates/updates templates in DocuSign using mapped objects. Handles feedback, errors, and reporting.
|
||||||
|
- **Validation/QA Layer**: Compares final artifacts, runs coverage and correctness checks, supports dry-run/test modes.
|
||||||
|
- **Testing/Scenario Folder**: Sample templates and responses (see `/sample-templates/`) and mapping/transform test cases.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Adobe Sign API] -->|Extract| B[Raw JSON]
|
||||||
|
B -->|Transform/Map| C[Canonical Model]
|
||||||
|
C -->|Ingest| D[DocuSign API]
|
||||||
|
D -->|Validate| E[QA/Reporting]
|
||||||
|
E -->|Feedback| B
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Extract Adobe template (metadata, fields, roles, workflows)
|
||||||
|
2. Pass to transform/mapping functions (per field/role/conditional)
|
||||||
|
3. Generate canonical model; attempt creation in DocuSign
|
||||||
|
4. Log result; pull DocuSign result and validate against input
|
||||||
|
5. Drop all validated or problematic test scenarios in `/sample-templates/` or a new `tests/` folder for regression & future QA
|
||||||
|
|
||||||
|
## Key Design Decisions & Logger
|
||||||
|
- Focus on batch/parallelization via pipelined scripts/modules
|
||||||
|
- Use local cache of all raw API payloads for traceability
|
||||||
|
- Mapping module must be testable with static samples (no account needed at first)
|
||||||
|
- Agent harness structure for project traceability, autonomous improvement
|
||||||
|
- **Decision Log** (expand as project runs):
|
||||||
|
- [2026-04-14] Start with static JSON tests and pure transforms before integrating live API. Document all lossy mappings inline in mapping functions & doc.
|
||||||
|
- [2026-04-14] Capture all feature-mapping challenges (fields, roles) as they appear in real-world test cases and update this doc.
|
||||||
|
|
||||||
|
## Extensibility
|
||||||
|
- Designed for: new field types, more templates, transform plugins
|
||||||
|
- Support “mapping hints” or forced overrides for ambiguous/complex field cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Update as architecture/requirements change. Generated by Cleo (2026-04-14).*
|
||||||
|
|
@ -1,18 +1,30 @@
|
||||||
# Field Mapping: Adobe Sign → DocuSign
|
# Field Mapping: Adobe Sign → DocuSign
|
||||||
|
|
||||||
This doc will be used to track direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties.
|
This doc tracks direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties.
|
||||||
|
|
||||||
## Simple Field Type Mapping
|
## Field Type Mapping (inputType + contentType + validation → DocuSign tab)
|
||||||
| Adobe Sign Type | DocuSign Tab Type | Notes |
|
|
||||||
|---------------------|-------------------------|------------------------|
|
Adobe Sign requires `inputType`, `contentType`, and sometimes `validation` to determine the correct DocuSign tab.
|
||||||
| TEXT_FIELD | text | Valid for plain text |
|
Source: Adobe Sign UI "Change field type" dropdown (all 15 types) + API field data.
|
||||||
| SIGNATURE | signHere | |
|
|
||||||
| CHECKBOX | checkbox | |
|
| Adobe UI Label | inputType | contentType | validation | DocuSign Tab | Notes |
|
||||||
| DATE | dateSigned | May need transform |
|
|---------------------|-------------------|------------------|------------|----------------------|--------------------------------------------|
|
||||||
| RADIO | radio | Group mapping required |
|
| Signature | SIGNATURE | SIGNATURE | — | signHereTabs | |
|
||||||
| DROPDOWN | list | Data mapping |
|
| Initials | SIGNATURE | SIGNER_INITIALS | — | initialHereTabs | NOT a full signature |
|
||||||
| APPROVER | signer/approver role | DocuSign roles more flexible |
|
| Recipient name | TEXT_FIELD | SIGNER_NAME | — | fullNameTabs | Auto-populated from signer profile |
|
||||||
| ... | ... | ... |
|
| Recipient email | TEXT_FIELD | SIGNER_EMAIL | — | emailAddressTabs | Auto-populated from signer profile |
|
||||||
|
| Date of signing | TEXT_FIELD | SIGNATURE_DATE | — | dateSignedTabs | Auto-populated on signing |
|
||||||
|
| Text | TEXT_FIELD | DATA | STRING | textTabs | |
|
||||||
|
| Date | TEXT_FIELD | DATA | DATE | dateTabs | User-entered date (not auto-signed date) |
|
||||||
|
| Number | TEXT_FIELD | DATA | NUMBER | numberTabs | |
|
||||||
|
| Drop-down menu | DROP_DOWN | DATA | — | listTabs | Options from hiddenOptions array |
|
||||||
|
| Attachments | FILE_CHOOSER | DATA | — | signerAttachmentTabs | Manual review recommended |
|
||||||
|
| Participation stamp | PARTICIPATION_STAMP | — | — | (skipped) | No DocuSign equivalent |
|
||||||
|
| Image | INLINE_IMAGE | DATA | — | (skipped) | No DocuSign equivalent |
|
||||||
|
| Company | TEXT_FIELD | COMPANY or SIGNER_COMPANY | — | companyTabs | Auto-populated from signer profile. API returns `SIGNER_COMPANY` when set via UI. |
|
||||||
|
| Title | TEXT_FIELD | TITLE or SIGNER_TITLE | — | titleTabs | Auto-populated from signer profile. API returns `SIGNER_TITLE` when set via UI. |
|
||||||
|
| Stamp | STAMP | — | — | (skipped) | No DocuSign equivalent |
|
||||||
|
| Signature block | BLOCK | SIGNATURE_BLOCK | — | signHereTabs | Composite block — mapped to sign-here |
|
||||||
|
|
||||||
## Role/Recipient Mapping
|
## Role/Recipient Mapping
|
||||||
| Adobe Field | DocuSign Field | Notes |
|
| Adobe Field | DocuSign Field | Notes |
|
||||||
|
|
@ -20,12 +32,30 @@ This doc will be used to track direct mappings and required transforms between A
|
||||||
| recipientSetRole | role (signer, etc.) | Matching by role name |
|
| recipientSetRole | role (signer, etc.) | Matching by role name |
|
||||||
| recipientSetMemberInfos.email | role.email | |
|
| recipientSetMemberInfos.email | role.email | |
|
||||||
|
|
||||||
|
## Known Edge Cases & Decision Log
|
||||||
|
- [2026-04-14] DocuSign checkboxes must be uniquely tab-labeled and mapped to a recipient; Adobe Sign sometimes groups these differently.
|
||||||
|
- [2026-04-14] Date fields on Adobe may include validation Adobe-only, which needs stripping or custom mapping for DocuSign’s `dateSigned`.
|
||||||
|
- [2026-04-14] Conditional logic for showing/hiding fields in Adobe is not always supported in DocuSign (needs review for each case).
|
||||||
|
- [2026-04-15] `numberTabs` API bug: DocuSign API accepts `numberTabs` in the template JSON, but the created template displays as a Text field with "Numbers" validation in the editor. Functionally equivalent at signing time; visual/semantic discrepancy only. No API workaround known.
|
||||||
|
- [2026-04-15] Multi-location fields: Adobe Sign fields can have multiple `locations` (cloned/synced instances). DocuSign equivalent is tab merging — multiple tabs with the same `tabLabel` sync their value. Our compose script now emits one tab per location for all data-entry types. See `PLATFORM-QUIRKS.md` for full details.
|
||||||
|
- [2026-04-15] Tab width required: DocuSign text-entry tabs render as a vertical line if `width` is omitted. Always pass `width` (and `height`) from the Adobe Sign location. Minimum 120pt enforced.
|
||||||
|
|
||||||
## Workflow Feature Mapping (Rough)
|
## Workflow Feature Mapping (Rough)
|
||||||
- Sequential routing → Recipient order
|
- Sequential routing → Recipient order
|
||||||
- Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign)
|
- Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign)
|
||||||
- Conditional logic → Needs review, possible via DocuSign conditional tabs/logic
|
- Conditional logic → Needs review, possible via DocuSign conditional tabs/logic
|
||||||
|
|
||||||
|
## Transform Formulas & Known Mapping Gaps
|
||||||
|
|
||||||
|
- **Coordinate translation:** If Adobe origin differs from DocuSign, map as:
|
||||||
|
`docusign_left = adobe_left // or apply offset, scale, etc.`
|
||||||
|
- **Radio group flattening:** Merge Adobe radios with `radioGroup` into DocuSign `radio` tab, setting all options explicitly.
|
||||||
|
- **Missing/ambiguous features:**
|
||||||
|
- DocuSign formulas (no mapping in Adobe Sign) — flag for manual rewrite
|
||||||
|
- Adobe advanced field validations (regex, custom scripts) — usually skipped or mapped to best-effort validation in DocuSign
|
||||||
|
|
||||||
## To Do
|
## To Do
|
||||||
- Add table for conditional logic/rule mapping
|
- Add table for conditional logic/rule mapping
|
||||||
- Add validation/transforms needed for field masks, validation, default values
|
- Add validation/transforms needed for field masks, validation, default values
|
||||||
|
- Document more edge cases as they are discovered in real samples
|
||||||
- Collect pain points/edge cases for high-fidelity migration
|
- Collect pain points/edge cases for high-fidelity migration
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
requests
|
requests
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pydantic
|
pydantic
|
||||||
|
PyJWT>=2.0
|
||||||
|
cryptography
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"fieldName": "EmployeeName",
|
||||||
|
"type": "TEXT_FIELD",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 100, "top": 220, "width": 200, "height": 15 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "Signature",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 2, "rect": { "left": 150, "top": 420, "width": 100, "height": 30 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"libraryDocumentId": "nda-001",
|
||||||
|
"name": "NDA Template",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"ownerEmail": "manager@example.com",
|
||||||
|
"fileInfos": [
|
||||||
|
{ "label": "NDA.pdf", "url": "https://example.com/nda.pdf" }
|
||||||
|
],
|
||||||
|
"recipientsListInfo": [
|
||||||
|
{
|
||||||
|
"recipientSetRole": "SIGNER",
|
||||||
|
"recipientSetMemberInfos": [
|
||||||
|
{ "name": "Employee", "email": "employee@example.com" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipientSetRole": "APPROVER",
|
||||||
|
"recipientSetMemberInfos": [
|
||||||
|
{ "name": "Manager", "email": "manager@example.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"fieldName": "EmployeeName",
|
||||||
|
"type": "TEXT_FIELD",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 100, "top": 200, "width": 200, "height": 15 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "StartDate",
|
||||||
|
"type": "DATE",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 100, "top": 240, "width": 120, "height": 15 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "Position",
|
||||||
|
"type": "DROPDOWN",
|
||||||
|
"required": true,
|
||||||
|
"items": ["Manager", "Engineer", "Tech", "HR"],
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 100, "top": 280, "width": 120, "height": 15 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "Benefits",
|
||||||
|
"type": "CHECKBOX",
|
||||||
|
"required": false,
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 100, "top": 320, "width": 12, "height": 12 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "CommuteOption",
|
||||||
|
"type": "RADIO",
|
||||||
|
"required": false,
|
||||||
|
"radioGroup": "CommuteGroup",
|
||||||
|
"items": ["Car", "Transit", "Bike"],
|
||||||
|
"locations": [
|
||||||
|
{ "pageNumber": 1, "rect": { "left": 100, "top": 360, "width": 12, "height": 12 } },
|
||||||
|
{ "pageNumber": 1, "rect": { "left": 140, "top": 360, "width": 12, "height": 12 } },
|
||||||
|
{ "pageNumber": 1, "rect": { "left": 180, "top": 360, "width": 12, "height": 12 } }
|
||||||
|
],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "HRNotes",
|
||||||
|
"type": "TEXT_FIELD",
|
||||||
|
"required": false,
|
||||||
|
"readOnly": true,
|
||||||
|
"locations": [ { "pageNumber": 2, "rect": { "left": 100, "top": 200, "width": 220, "height": 60 } } ],
|
||||||
|
"recipientIndex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "EmployeeSignature",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 2, "rect": { "left": 100, "top": 300, "width": 120, "height": 32 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "HRSignature",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 2, "rect": { "left": 300, "top": 300, "width": 120, "height": 32 } } ],
|
||||||
|
"recipientIndex": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"libraryDocumentId": "onboarding-003",
|
||||||
|
"name": "Employee Onboarding Form",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"ownerEmail": "hr@example.com",
|
||||||
|
"fileInfos": [
|
||||||
|
{ "label": "OnboardingForm.pdf", "url": "https://example.com/onboardingform.pdf" }
|
||||||
|
],
|
||||||
|
"recipientsListInfo": [
|
||||||
|
{
|
||||||
|
"recipientSetRole": "SIGNER",
|
||||||
|
"recipientSetMemberInfos": [
|
||||||
|
{ "name": "Employee", "email": "employee@example.com" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipientSetRole": "APPROVER",
|
||||||
|
"recipientSetMemberInfos": [
|
||||||
|
{ "name": "HR Representative", "email": "hr@example.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"fieldName": "PurchasePrice",
|
||||||
|
"type": "TEXT_FIELD",
|
||||||
|
"required": true,
|
||||||
|
"locations": [ { "pageNumber": 1, "rect": { "left": 180, "top": 170, "width": 150, "height": 14 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "BuyerSign",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"locations": [ { "pageNumber": 3, "rect": { "left": 200, "top": 375, "width": 120, "height": 32 } } ],
|
||||||
|
"recipientIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldName": "SellerSign",
|
||||||
|
"type": "SIGNATURE",
|
||||||
|
"locations": [ { "pageNumber": 3, "rect": { "left": 420, "top": 375, "width": 120, "height": 32 } } ],
|
||||||
|
"recipientIndex": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"libraryDocumentId": "sales-002",
|
||||||
|
"name": "Sales Agreement v2",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"ownerEmail": "saleslead@example.com",
|
||||||
|
"fileInfos": [
|
||||||
|
{ "label": "SalesAgreement.pdf", "url": "https://example.com/salesagreement.pdf" }
|
||||||
|
],
|
||||||
|
"recipientsListInfo": [
|
||||||
|
{
|
||||||
|
"recipientSetRole": "SIGNER",
|
||||||
|
"recipientSetMemberInfos": [
|
||||||
|
{ "name": "Buyer", "email": "buyer@company.com" },
|
||||||
|
{ "name": "Seller", "email": "seller@company.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SHARD = "eu2"
|
||||||
|
TOKEN_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/token"
|
||||||
|
REDIRECT_URI = "https://localhost:8080/callback"
|
||||||
|
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_access_token():
|
||||||
|
client_id = os.getenv("ADOBE_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("ADOBE_CLIENT_SECRET")
|
||||||
|
refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
||||||
|
|
||||||
|
if not all([client_id, client_secret, refresh_token]):
|
||||||
|
raise RuntimeError("Missing credentials for token refresh. Run src/auth_adobe.py first.")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
}
|
||||||
|
resp = requests.post(TOKEN_URL, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
new_token = resp.json()["access_token"]
|
||||||
|
|
||||||
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
set_key(abs_env, "ADOBE_ACCESS_TOKEN", new_token)
|
||||||
|
os.environ["ADOBE_ACCESS_TOKEN"] = new_token
|
||||||
|
return new_token
|
||||||
|
|
||||||
|
|
||||||
|
def adobe_api_post_multipart(endpoint, files, data=None):
|
||||||
|
"""Upload a file via multipart/form-data (e.g. transient documents)."""
|
||||||
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
resp = requests.post(url, headers=headers, files=files, data=data or {})
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = _refresh_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.post(url, headers=headers, files=files, data=data or {})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def adobe_api_post_json(endpoint, body):
|
||||||
|
"""POST JSON body to an Adobe Sign endpoint."""
|
||||||
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
resp = requests.post(url, headers=headers, json=body)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = _refresh_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.post(url, headers=headers, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def adobe_api_put_json(endpoint, body):
|
||||||
|
"""PUT JSON body to an Adobe Sign endpoint."""
|
||||||
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
resp = requests.put(url, headers=headers, json=body)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = _refresh_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.put(url, headers=headers, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def adobe_api_get_bytes(endpoint):
|
||||||
|
"""Download binary content (e.g. PDF files) from the Adobe Sign API."""
|
||||||
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
resp = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = _refresh_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def adobe_api_get(endpoint, params=None):
|
||||||
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
# Token expired — refresh and retry once
|
||||||
|
token = _refresh_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
library_docs = adobe_api_get("libraryDocuments")
|
||||||
|
print("Library Documents:", library_docs)
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""
|
||||||
|
One-time Adobe Sign OAuth setup.
|
||||||
|
|
||||||
|
Run this script once to authorize the app and save tokens to .env:
|
||||||
|
python src/auth_adobe.py
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Set ADOBE_CLIENT_ID and ADOBE_CLIENT_SECRET in .env (or export them)
|
||||||
|
- Redirect URI in your Adobe Sign app must be set to: https://localhost
|
||||||
|
|
||||||
|
After authorizing in the browser, the page will fail to load (that's expected).
|
||||||
|
Copy the full URL from the address bar and paste it when prompted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from urllib.parse import urlencode, urlparse, parse_qs
|
||||||
|
|
||||||
|
from dotenv import load_dotenv, set_key
|
||||||
|
import requests
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SHARD = "eu2"
|
||||||
|
AUTH_URL = f"https://secure.{SHARD}.adobesign.com/public/oauth/v2"
|
||||||
|
TOKEN_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/token"
|
||||||
|
REDIRECT_URI = "https://localhost:8080/callback"
|
||||||
|
SCOPES = "library_read:self library_write:self user_read:self"
|
||||||
|
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_url(client_id):
|
||||||
|
params = {
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": client_id,
|
||||||
|
"scope": SCOPES,
|
||||||
|
}
|
||||||
|
return f"{AUTH_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_code(redirected_url):
|
||||||
|
parsed = urlparse(redirected_url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
if "code" not in params:
|
||||||
|
error = params.get("error_description", params.get("error", ["unknown"]))[0]
|
||||||
|
raise ValueError(f"No code in URL. Error: {error}")
|
||||||
|
return params["code"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_tokens(code, client_id, client_secret):
|
||||||
|
data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
}
|
||||||
|
resp = requests.post(TOKEN_URL, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def save_tokens(tokens):
|
||||||
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
set_key(abs_env, "ADOBE_ACCESS_TOKEN", tokens["access_token"])
|
||||||
|
if "refresh_token" in tokens:
|
||||||
|
set_key(abs_env, "ADOBE_REFRESH_TOKEN", tokens["refresh_token"])
|
||||||
|
set_key(abs_env, "ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
print(f"Tokens saved to {abs_env}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
client_id = os.getenv("ADOBE_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("ADOBE_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
print("ERROR: ADOBE_CLIENT_ID and ADOBE_CLIENT_SECRET must be set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
url = get_auth_url(client_id)
|
||||||
|
print(f"\nOpening browser for authorization...")
|
||||||
|
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
print("After authorizing, the browser will land on a page that fails to load.")
|
||||||
|
print("That's expected — just copy the full URL from the address bar and paste it here.\n")
|
||||||
|
redirected_url = input("Paste the redirect URL: ").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = extract_code(redirected_url)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Exchanging code for tokens...")
|
||||||
|
tokens = exchange_code_for_tokens(code, client_id, client_secret)
|
||||||
|
save_tokens(tokens)
|
||||||
|
print("Done. You can now run the migrator.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
"""
|
||||||
|
compose_docusign_template.py
|
||||||
|
----------------------------
|
||||||
|
Converts a downloaded Adobe Sign template folder into a DocuSign
|
||||||
|
envelopeTemplate JSON that can be posted directly to the DocuSign
|
||||||
|
Templates API via:
|
||||||
|
|
||||||
|
node packages/esign-direct/build/cli.js templates create --file <output.json>
|
||||||
|
|
||||||
|
Key rules applied:
|
||||||
|
- Tabs grouped by type: textTabs, signHereTabs, dateSignedTabs,
|
||||||
|
listTabs, checkboxTabs, radioGroupTabs, signerAttachmentTabs
|
||||||
|
- required / locked must be strings "true" / "false", not booleans
|
||||||
|
- listTabs need listItems: [{text, value}] not a plain string array
|
||||||
|
- radioGroupTabs use groupName + radios[] with per-option page/x/y
|
||||||
|
- Coordinates: Adobe top is from TOP of page; DocuSign yPosition is
|
||||||
|
from BOTTOM. For US Letter (792pt): y = PAGE_HEIGHT - top - height
|
||||||
|
- Signers in templates are role-placeholders (no email/name)
|
||||||
|
- No top-level "status" field (belongs on envelope sends, not templates)
|
||||||
|
|
||||||
|
Field type coverage:
|
||||||
|
Mapped: TEXT_FIELD, SIGNATURE, CHECKBOX, DATE, DROP_DOWN, RADIO, BLOCK
|
||||||
|
Partial: FILE_CHOOSER → signerAttachmentTabs (with warning)
|
||||||
|
Skipped: INLINE_IMAGE (no DocuSign equivalent — warning logged)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DOCUMENT_ID = "1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Coordinate translation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Minimum width for text-entry tabs so they render as a visible box rather
|
||||||
|
# than a vertical line. ~120pt ≈ 15 average characters at 8pt/char.
|
||||||
|
MIN_TEXT_WIDTH = 120
|
||||||
|
|
||||||
|
|
||||||
|
def loc_to_docusign(loc: dict) -> tuple[str, str, str, str, str]:
|
||||||
|
"""
|
||||||
|
Convert an Adobe Sign location dict to DocuSign (page, x, y, width, height) strings.
|
||||||
|
Both Adobe Sign and DocuSign measure from the top-left corner of the page,
|
||||||
|
with y increasing downward — no coordinate inversion needed.
|
||||||
|
Width is clamped to MIN_TEXT_WIDTH so text fields render as visible boxes.
|
||||||
|
"""
|
||||||
|
page = str(loc["pageNumber"])
|
||||||
|
x = str(int(loc["left"]))
|
||||||
|
y = str(int(loc["top"]))
|
||||||
|
width = str(max(int(loc.get("width", MIN_TEXT_WIDTH)), MIN_TEXT_WIDTH))
|
||||||
|
height = str(int(loc.get("height", 24)))
|
||||||
|
return page, x, y, width, height
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recipients: derive from form field assignees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def derive_recipients(fields: list) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Build an ordered list of recipient role placeholders from the assignee
|
||||||
|
values on each field (e.g. "recipient0", "recipient1").
|
||||||
|
Returns [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}, ...]
|
||||||
|
"""
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for f in fields:
|
||||||
|
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
|
||||||
|
if assignee not in seen:
|
||||||
|
try:
|
||||||
|
idx = int(assignee.replace("recipient", ""))
|
||||||
|
except ValueError:
|
||||||
|
idx = len(seen)
|
||||||
|
seen[assignee] = idx
|
||||||
|
|
||||||
|
recipients = sorted(
|
||||||
|
[{"assignee": k, "index": v, "roleName": f"Signer {v + 1}"} for k, v in seen.items()],
|
||||||
|
key=lambda r: r["index"],
|
||||||
|
)
|
||||||
|
return recipients if recipients else [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}]
|
||||||
|
|
||||||
|
|
||||||
|
def assignee_to_index(assignee: str | None, recipients: list[dict]) -> int:
|
||||||
|
if not assignee:
|
||||||
|
return 0
|
||||||
|
for r in recipients:
|
||||||
|
if r["assignee"] == assignee:
|
||||||
|
return r["index"]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tab builder helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_sized_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
|
||||||
|
"""Build one sized DocuSign tab from a single Adobe Sign location."""
|
||||||
|
page, x, y, width, height = loc_to_docusign(loc)
|
||||||
|
tab = {
|
||||||
|
"tabLabel": label,
|
||||||
|
"documentId": DOCUMENT_ID,
|
||||||
|
"pageNumber": page,
|
||||||
|
"xPosition": x,
|
||||||
|
"yPosition": y,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
tab.update(extra)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
|
||||||
|
def _make_base_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
|
||||||
|
"""Build one unsized DocuSign tab (for signature/checkbox fields)."""
|
||||||
|
page, x, y, _w, _h = loc_to_docusign(loc)
|
||||||
|
tab = {
|
||||||
|
"tabLabel": label,
|
||||||
|
"documentId": DOCUMENT_ID,
|
||||||
|
"pageNumber": page,
|
||||||
|
"xPosition": x,
|
||||||
|
"yPosition": y,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
tab.update(extra)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
|
||||||
|
def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
|
||||||
|
"""
|
||||||
|
Emit one sized tab per location with the same tabLabel.
|
||||||
|
|
||||||
|
Adobe Sign allows a single field to have multiple locations (cloned/linked
|
||||||
|
instances). DocuSign replicates this via tab merging: tabs that share a
|
||||||
|
tabLabel auto-sync their value at signing time. Applies to all data-entry
|
||||||
|
tab types: textTabs, numberTabs, dateTabs, dateSignedTabs, fullNameTabs,
|
||||||
|
emailAddressTabs, companyTabs, titleTabs, listTabs, checkboxTabs.
|
||||||
|
"""
|
||||||
|
return [_make_sized_tab(loc, label, extra) for loc in locations]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tab builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_tabs_for_field(field: dict, warnings: list) -> dict:
|
||||||
|
"""
|
||||||
|
Convert one Adobe Sign field into the correct DocuSign tabs structure.
|
||||||
|
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
|
||||||
|
Unmappable fields are skipped and a warning is appended.
|
||||||
|
"""
|
||||||
|
input_type = field.get("inputType", "")
|
||||||
|
label = field.get("name", "unnamed")
|
||||||
|
locations = field.get("locations", [])
|
||||||
|
required_str = "true" if field.get("required", False) else "false"
|
||||||
|
locked_str = "true" if field.get("readOnly", False) else "false"
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
content_type = field.get("contentType", "")
|
||||||
|
validation = field.get("validation", "")
|
||||||
|
|
||||||
|
if input_type == "TEXT_FIELD":
|
||||||
|
if content_type == "SIGNATURE_DATE":
|
||||||
|
# Auto-populated with the signing date
|
||||||
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
||||||
|
elif content_type == "SIGNER_NAME":
|
||||||
|
# Auto-populated with the signer's full name
|
||||||
|
return {"fullNameTabs": _sized_tabs(locations, label)}
|
||||||
|
elif content_type == "SIGNER_EMAIL":
|
||||||
|
# Auto-populated with the signer's email address
|
||||||
|
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
||||||
|
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
||||||
|
# Auto-populated with the signer's company
|
||||||
|
return {"companyTabs": _sized_tabs(locations, label)}
|
||||||
|
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
||||||
|
# Auto-populated with the signer's title
|
||||||
|
return {"titleTabs": _sized_tabs(locations, label)}
|
||||||
|
elif content_type == "DATA" and validation == "DATE":
|
||||||
|
# User-entered date field (not auto-signed date)
|
||||||
|
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
|
elif content_type == "DATA" and validation == "NUMBER":
|
||||||
|
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
|
else:
|
||||||
|
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
|
|
||||||
|
elif input_type == "SIGNATURE":
|
||||||
|
# Each signature/initials location is an independent signing action —
|
||||||
|
# emit one tab per location but do not size them (DocuSign controls size)
|
||||||
|
if content_type == "SIGNER_INITIALS":
|
||||||
|
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
else:
|
||||||
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
|
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
|
||||||
|
# Composite signature block — map to signHere at block's location
|
||||||
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
|
elif input_type == "DATE":
|
||||||
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
||||||
|
|
||||||
|
elif input_type == "CHECKBOX":
|
||||||
|
return {"checkboxTabs": _sized_tabs(locations, label, {"required": required_str})}
|
||||||
|
|
||||||
|
elif input_type == "DROP_DOWN":
|
||||||
|
options = field.get("hiddenOptions") or field.get("visibleOptions") or []
|
||||||
|
list_items = [{"text": str(v), "value": str(v)} for v in options if v]
|
||||||
|
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
|
||||||
|
|
||||||
|
elif input_type == "RADIO":
|
||||||
|
# Each location is one radio button within the group — not tab merging
|
||||||
|
options = field.get("hiddenOptions") or []
|
||||||
|
radios = []
|
||||||
|
for i, loc in enumerate(locations):
|
||||||
|
pg, rx, ry, _rw, _rh = loc_to_docusign(loc)
|
||||||
|
value = options[i] if i < len(options) else str(i + 1)
|
||||||
|
radios.append({"pageNumber": pg, "xPosition": rx, "yPosition": ry, "value": value})
|
||||||
|
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
|
||||||
|
|
||||||
|
elif input_type == "FILE_CHOOSER":
|
||||||
|
warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)")
|
||||||
|
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
|
||||||
|
return {"signerAttachmentTabs": [tab]}
|
||||||
|
|
||||||
|
elif input_type == "INLINE_IMAGE":
|
||||||
|
warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
elif input_type in ("STAMP", "PARTICIPATION_STAMP"):
|
||||||
|
warnings.append(f"{input_type} '{label}' → skipped (no DocuSign equivalent)")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
else:
|
||||||
|
warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tabs(acc: dict, new: dict) -> dict:
|
||||||
|
for key, tabs in new.items():
|
||||||
|
acc.setdefault(key, []).extend(tabs)
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main compose function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]:
|
||||||
|
"""
|
||||||
|
Build a DocuSign template JSON from a downloaded Adobe Sign template folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_dir: path to a downloads/<template-name>/ folder containing
|
||||||
|
metadata.json, form_fields.json, documents.json, and a PDF
|
||||||
|
output_path: where to write the resulting DocuSign template JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(template_dict, warnings_list)
|
||||||
|
"""
|
||||||
|
template_dir = Path(template_dir)
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
# Load source files
|
||||||
|
metadata = json.loads((template_dir / "metadata.json").read_text())
|
||||||
|
fields_data = json.loads((template_dir / "form_fields.json").read_text())
|
||||||
|
documents_data = json.loads((template_dir / "documents.json").read_text())
|
||||||
|
fields: list[dict] = fields_data.get("fields", [])
|
||||||
|
|
||||||
|
# Find the PDF file
|
||||||
|
pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name]
|
||||||
|
if not pdf_files:
|
||||||
|
raise FileNotFoundError(f"No PDF found in {template_dir}")
|
||||||
|
pdf_path = pdf_files[0]
|
||||||
|
pdf_b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
|
||||||
|
|
||||||
|
# Document name from documents.json
|
||||||
|
doc_info = documents_data.get("documents", [{}])[0]
|
||||||
|
doc_name = doc_info.get("name", pdf_path.name)
|
||||||
|
if not doc_name.lower().endswith(".pdf"):
|
||||||
|
doc_name = Path(doc_name).stem + ".pdf"
|
||||||
|
|
||||||
|
# Derive recipients from form field assignees
|
||||||
|
recipients = derive_recipients(fields)
|
||||||
|
|
||||||
|
# Build signers with empty tab groups
|
||||||
|
signers = []
|
||||||
|
for r in recipients:
|
||||||
|
signers.append({
|
||||||
|
"roleName": r["roleName"],
|
||||||
|
"recipientId": str(r["index"] + 1),
|
||||||
|
"routingOrder": str(r["index"] + 1),
|
||||||
|
"tabs": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Assign tabs to the correct signer
|
||||||
|
for field in fields:
|
||||||
|
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
||||||
|
idx = assignee_to_index(assignee, recipients)
|
||||||
|
if idx >= len(signers):
|
||||||
|
idx = 0
|
||||||
|
tabs = build_tabs_for_field(field, warnings)
|
||||||
|
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
|
||||||
|
|
||||||
|
template = {
|
||||||
|
"name": metadata.get("name", template_dir.name),
|
||||||
|
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"documentBase64": pdf_b64,
|
||||||
|
"name": doc_name,
|
||||||
|
"fileExtension": "pdf",
|
||||||
|
"documentId": DOCUMENT_ID,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recipients": {
|
||||||
|
"signers": signers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(template, f, indent=2)
|
||||||
|
|
||||||
|
return template, warnings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
downloads_dir = Path(__file__).parent.parent / "downloads"
|
||||||
|
if not downloads_dir.exists():
|
||||||
|
print("No downloads/ folder found. Run download_templates.py first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for template_dir in sorted(downloads_dir.iterdir()):
|
||||||
|
if not template_dir.is_dir():
|
||||||
|
continue
|
||||||
|
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
|
||||||
|
print(f"\n--- {template_dir.name} ---")
|
||||||
|
try:
|
||||||
|
_, warnings = compose_template(str(template_dir), str(output_path))
|
||||||
|
print(f" Written: {output_path}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" WARNING: {w}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""
|
||||||
|
create_adobe_template.py
|
||||||
|
------------------------
|
||||||
|
Creates "Paul Adobe Template" in Adobe Sign by:
|
||||||
|
1. Using the exact field positions from the downloaded David Tag Demo Form
|
||||||
|
2. Adding 4 extra fields (Number, Email, Company, Title) in available gaps
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 src/create_adobe_template.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from adobe_api import adobe_api_post_multipart, adobe_api_post_json, adobe_api_put_json
|
||||||
|
|
||||||
|
PDF_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..",
|
||||||
|
"downloads", "David Tag Demo Form__CBJCHBCA", "Tag Demo Form_docx_pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
FIELDS_JSON_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..",
|
||||||
|
"downloads", "David Tag Demo Form__CBJCHBCA", "form_fields.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_NAME = "Paul Adobe Template"
|
||||||
|
|
||||||
|
# Keys the Adobe Sign API accepts when writing form fields.
|
||||||
|
# We strip server-generated metadata (origin, signerIndex, font/border styling).
|
||||||
|
ALLOWED_KEYS = {
|
||||||
|
"name", "inputType", "contentType", "validation", "validationData",
|
||||||
|
"required", "readOnly", "locations", "assignee",
|
||||||
|
"hiddenOptions", "visibleOptions", "defaultValue",
|
||||||
|
"masked", "maskingText", "calculated", "urlOverridable",
|
||||||
|
"minLength", "maxLength", "minValue", "maxValue",
|
||||||
|
"validationErrMsg", "currency", "conditionalAction",
|
||||||
|
"radioCheckType",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extra fields that cover types not present in the original David Tag form:
|
||||||
|
# - NUMBER validation (TEXT_FIELD / DATA / NUMBER)
|
||||||
|
# - SIGNER_EMAIL auto-fill
|
||||||
|
# - COMPANY auto-fill
|
||||||
|
# - TITLE auto-fill
|
||||||
|
#
|
||||||
|
# Placed in the two clear vertical gaps in the original layout:
|
||||||
|
# Gap A: y=375–432 (between checkboxes and Initials 1), right side (left=350)
|
||||||
|
# Gap B: y=513–582 (between Date of Signing 1 and Signature block), left side (left=106)
|
||||||
|
EXTRA_FIELDS = [
|
||||||
|
{
|
||||||
|
"name": "Company",
|
||||||
|
"inputType": "TEXT_FIELD", "contentType": "COMPANY", "validation": "NONE",
|
||||||
|
"required": False, "readOnly": False,
|
||||||
|
"locations": [{"pageNumber": 1, "top": 378, "left": 350, "width": 150, "height": 24}],
|
||||||
|
"assignee": "recipient0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Title",
|
||||||
|
"inputType": "TEXT_FIELD", "contentType": "TITLE", "validation": "NONE",
|
||||||
|
"required": False, "readOnly": False,
|
||||||
|
"locations": [{"pageNumber": 1, "top": 410, "left": 350, "width": 150, "height": 24}],
|
||||||
|
"assignee": "recipient0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Number Field",
|
||||||
|
"inputType": "TEXT_FIELD", "contentType": "DATA", "validation": "NUMBER",
|
||||||
|
"required": False, "readOnly": False,
|
||||||
|
"locations": [{"pageNumber": 1, "top": 516, "left": 106, "width": 150, "height": 24}],
|
||||||
|
"assignee": "recipient0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Recipient Email",
|
||||||
|
"inputType": "TEXT_FIELD", "contentType": "SIGNER_EMAIL", "validation": "NONE",
|
||||||
|
"required": False, "readOnly": True,
|
||||||
|
"locations": [{"pageNumber": 1, "top": 548, "left": 106, "width": 175, "height": 24}],
|
||||||
|
"assignee": "recipient0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_field(f):
|
||||||
|
"""Strip server-only keys, keep only what the write API accepts."""
|
||||||
|
out = {k: v for k, v in f.items() if k in ALLOWED_KEYS}
|
||||||
|
out.setdefault("validation", "NONE")
|
||||||
|
out.setdefault("required", False)
|
||||||
|
out.setdefault("readOnly", False)
|
||||||
|
out.setdefault("masked", False)
|
||||||
|
out.setdefault("maskingText", "*")
|
||||||
|
out.setdefault("calculated", False)
|
||||||
|
out.setdefault("urlOverridable", False)
|
||||||
|
out.setdefault("minLength", -1)
|
||||||
|
out.setdefault("maxLength", -1)
|
||||||
|
out.setdefault("minValue", -1.0)
|
||||||
|
out.setdefault("maxValue", -1.0)
|
||||||
|
out.setdefault("validationErrMsg", "")
|
||||||
|
out.setdefault("conditionalAction", {"anyOrAll": "ANY", "action": "SHOW"})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_fields():
|
||||||
|
with open(FIELDS_JSON_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
fields = [clean_field(field) for field in data["fields"]]
|
||||||
|
groups = data.get("formFieldGroups", [])
|
||||||
|
print(f" Loaded {len(fields)} fields from David Tag Demo Form download")
|
||||||
|
return fields, groups
|
||||||
|
|
||||||
|
|
||||||
|
def upload_transient_doc():
|
||||||
|
print(f"Uploading PDF: {PDF_PATH}")
|
||||||
|
with open(PDF_PATH, "rb") as f:
|
||||||
|
files = {"File": ("Tag Demo Form.pdf", f, "application/pdf")}
|
||||||
|
result = adobe_api_post_multipart("transientDocuments", files=files)
|
||||||
|
doc_id = result["transientDocumentId"]
|
||||||
|
print(f" Transient document ID: {doc_id}")
|
||||||
|
return doc_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_library_doc(transient_id):
|
||||||
|
print(f"Creating library document '{TEMPLATE_NAME}'...")
|
||||||
|
body = {
|
||||||
|
"fileInfos": [{"transientDocumentId": transient_id}],
|
||||||
|
"name": TEMPLATE_NAME,
|
||||||
|
"templateTypes": ["DOCUMENT", "FORM_FIELD_LAYER"],
|
||||||
|
"sharingMode": "USER",
|
||||||
|
"state": "ACTIVE",
|
||||||
|
}
|
||||||
|
result = adobe_api_post_json("libraryDocuments", body)
|
||||||
|
lib_id = result["id"]
|
||||||
|
print(f" Library document ID: {lib_id}")
|
||||||
|
return lib_id
|
||||||
|
|
||||||
|
|
||||||
|
def put_form_fields(lib_id, source_fields, groups):
|
||||||
|
all_fields = source_fields + [clean_field(f) for f in EXTRA_FIELDS]
|
||||||
|
print(f"Writing {len(all_fields)} fields ({len(source_fields)} original + {len(EXTRA_FIELDS)} extra)...")
|
||||||
|
|
||||||
|
body = {"fields": all_fields}
|
||||||
|
if groups:
|
||||||
|
body["formFieldGroups"] = groups
|
||||||
|
|
||||||
|
result = adobe_api_put_json(f"libraryDocuments/{lib_id}/formFields", body)
|
||||||
|
saved = len(result.get("fields", []))
|
||||||
|
print(f" {saved} fields saved.")
|
||||||
|
for field in result.get("fields", []):
|
||||||
|
print(f" {field['inputType']:15} {field.get('contentType',''):20} '{field['name']}'")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(PDF_PATH):
|
||||||
|
print(f"ERROR: PDF not found at {PDF_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
if not os.path.exists(FIELDS_JSON_PATH):
|
||||||
|
print(f"ERROR: form_fields.json not found at {FIELDS_JSON_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
source_fields, groups = load_source_fields()
|
||||||
|
transient_id = upload_transient_doc()
|
||||||
|
lib_id = create_library_doc(transient_id)
|
||||||
|
put_form_fields(lib_id, source_fields, groups)
|
||||||
|
print(f"\nDone. Template '{TEMPLATE_NAME}' created (ID: {lib_id})")
|
||||||
|
print("Note: If Company/Title fields need their contentType set, do so in Adobe Sign UI.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""
|
||||||
|
docusign_auth.py
|
||||||
|
----------------
|
||||||
|
Handles DocuSign authentication for the migration toolkit.
|
||||||
|
|
||||||
|
Two flows:
|
||||||
|
JWT Grant — service-to-service, no user interaction. Used for all
|
||||||
|
normal API calls. Requires consent to have been granted.
|
||||||
|
Auth Code Grant — browser-based OAuth flow. Run once with --consent to
|
||||||
|
grant the app the 'impersonation' scope it needs for JWT.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 src/docusign_auth.py --consent # one-time browser consent
|
||||||
|
python3 src/docusign_auth.py # print a fresh access token (smoke test)
|
||||||
|
|
||||||
|
Required .env keys:
|
||||||
|
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
|
||||||
|
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
|
||||||
|
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
|
||||||
|
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
|
||||||
|
DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox)
|
||||||
|
or account.docusign.com (production)
|
||||||
|
DOCUSIGN_BASE_URL https://demo.docusign.net/restapi (sandbox)
|
||||||
|
or https://na3.docusign.net/restapi (prod, check your account)
|
||||||
|
|
||||||
|
For --consent only:
|
||||||
|
DOCUSIGN_CLIENT_SECRET OAuth client secret
|
||||||
|
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from urllib.parse import urlencode, urlparse, parse_qs
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||||
|
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JWT Grant
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_private_key():
|
||||||
|
key_path = os.getenv("DOCUSIGN_PRIVATE_KEY_PATH")
|
||||||
|
if not key_path:
|
||||||
|
raise RuntimeError("DOCUSIGN_PRIVATE_KEY_PATH is not set in .env")
|
||||||
|
key_path = os.path.expanduser(key_path)
|
||||||
|
if not os.path.exists(key_path):
|
||||||
|
raise RuntimeError(f"Private key not found: {key_path}")
|
||||||
|
with open(key_path, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_jwt_token():
|
||||||
|
"""Exchange a JWT assertion for a DocuSign access token."""
|
||||||
|
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
||||||
|
user_id = os.getenv("DOCUSIGN_USER_ID")
|
||||||
|
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||||
|
private_key = _load_private_key()
|
||||||
|
|
||||||
|
if not all([client_id, user_id]):
|
||||||
|
raise RuntimeError("DOCUSIGN_CLIENT_ID and DOCUSIGN_USER_ID must be set in .env")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"iss": client_id,
|
||||||
|
"sub": user_id,
|
||||||
|
"aud": auth_server,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 3600,
|
||||||
|
"scope": "signature impersonation",
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion = jwt.encode(payload, private_key, algorithm="RS256")
|
||||||
|
# PyJWT >= 2.0 returns str; older versions return bytes
|
||||||
|
if isinstance(assertion, bytes):
|
||||||
|
assertion = assertion.decode("utf-8")
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://{auth_server}/oauth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
"assertion": assertion,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 400 and "consent_required" in resp.text:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Consent not yet granted for this app/user combination.\n"
|
||||||
|
"Run: python3 src/docusign_auth.py --consent\n"
|
||||||
|
"Then retry."
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_access_token() -> str:
|
||||||
|
"""
|
||||||
|
Return a valid DocuSign access token, refreshing via JWT grant if needed.
|
||||||
|
Caches the token in .env to avoid unnecessary round-trips.
|
||||||
|
"""
|
||||||
|
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
|
||||||
|
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
|
||||||
|
|
||||||
|
if cached_token and cached_expiry:
|
||||||
|
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
token_data = _request_jwt_token()
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||||
|
|
||||||
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
set_key(abs_env, "DOCUSIGN_ACCESS_TOKEN", access_token)
|
||||||
|
set_key(abs_env, "DOCUSIGN_TOKEN_EXPIRY", str(expiry))
|
||||||
|
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
|
||||||
|
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth Code Grant — consent flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_consent_url():
|
||||||
|
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
||||||
|
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||||
|
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "signature impersonation",
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
}
|
||||||
|
return f"https://{auth_server}/oauth/auth?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _exchange_code(code: str):
|
||||||
|
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("DOCUSIGN_CLIENT_SECRET")
|
||||||
|
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||||
|
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
|
||||||
|
|
||||||
|
if not client_secret:
|
||||||
|
raise RuntimeError("DOCUSIGN_CLIENT_SECRET must be set in .env for the consent flow")
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://{auth_server}/oauth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
},
|
||||||
|
auth=(client_id, client_secret),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def run_consent_flow():
|
||||||
|
"""
|
||||||
|
Open a browser for the user to grant consent, then exchange the code for
|
||||||
|
an initial access token. After this succeeds, JWT grant will work.
|
||||||
|
"""
|
||||||
|
url = _build_consent_url()
|
||||||
|
print("\nOpening browser for DocuSign consent...")
|
||||||
|
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
print("Log in and click Allow. The browser will redirect to your redirect URI")
|
||||||
|
print("(the page may show an error — that's fine). Copy the full URL and paste it here.\n")
|
||||||
|
redirected_url = input("Paste the redirect URL: ").strip()
|
||||||
|
|
||||||
|
parsed = urlparse(redirected_url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if "error" in params:
|
||||||
|
error = params.get("error_description", params.get("error", ["unknown"]))[0]
|
||||||
|
print(f"ERROR: {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if "code" not in params:
|
||||||
|
print("ERROR: No authorization code found in the URL.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
code = params["code"][0]
|
||||||
|
print("Exchanging code for token...")
|
||||||
|
token_data = _exchange_code(code)
|
||||||
|
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||||
|
|
||||||
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
set_key(abs_env, "DOCUSIGN_ACCESS_TOKEN", access_token)
|
||||||
|
set_key(abs_env, "DOCUSIGN_TOKEN_EXPIRY", str(expiry))
|
||||||
|
|
||||||
|
print("Consent granted and token saved.")
|
||||||
|
print("JWT grant will now work for future calls — you won't need to run --consent again.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
||||||
|
parser.add_argument("--consent", action="store_true",
|
||||||
|
help="Run the Auth Code Grant consent flow (required once per user/app)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.consent:
|
||||||
|
run_consent_flow()
|
||||||
|
else:
|
||||||
|
token = get_access_token()
|
||||||
|
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""
|
||||||
|
Download Adobe Sign library templates to the local downloads/ folder.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 src/download_templates.py list
|
||||||
|
List all templates available in the account (no download).
|
||||||
|
|
||||||
|
python3 src/download_templates.py download
|
||||||
|
python3 src/download_templates.py download --all
|
||||||
|
Download all templates.
|
||||||
|
|
||||||
|
python3 src/download_templates.py download "Template Name"
|
||||||
|
Download the named template. If multiple templates share the same name,
|
||||||
|
the most recently modified one is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from adobe_api import adobe_api_get, adobe_api_get_bytes
|
||||||
|
|
||||||
|
DOWNLOADS_DIR = os.path.join(os.path.dirname(__file__), "..", "downloads")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_dirname(name):
|
||||||
|
return "".join(c if c.isalnum() or c in " -_" else "_" for c in name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_template_list():
|
||||||
|
result = adobe_api_get("libraryDocuments")
|
||||||
|
return result.get("libraryDocumentList", [])
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(_args):
|
||||||
|
print("Fetching template list...")
|
||||||
|
templates = fetch_template_list()
|
||||||
|
if not templates:
|
||||||
|
print("No templates found.")
|
||||||
|
return
|
||||||
|
print(f"{'Name':<45} {'Modified':<25} {'ID'}")
|
||||||
|
print("-" * 100)
|
||||||
|
for t in sorted(templates, key=lambda x: x.get("modifiedDate", ""), reverse=True):
|
||||||
|
print(f"{t['name']:<45} {t.get('modifiedDate', 'n/a'):<25} {t['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def download_one(template):
|
||||||
|
template_id = template["id"]
|
||||||
|
template_name = template["name"]
|
||||||
|
dir_name = f"{safe_dirname(template_name)}__{template_id[:8]}"
|
||||||
|
out_dir = os.path.join(DOWNLOADS_DIR, dir_name)
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"\n--- {template_name} ---")
|
||||||
|
|
||||||
|
metadata = adobe_api_get(f"libraryDocuments/{template_id}")
|
||||||
|
save_json(os.path.join(out_dir, "metadata.json"), metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
form_fields = adobe_api_get(f"libraryDocuments/{template_id}/formFields")
|
||||||
|
save_json(os.path.join(out_dir, "form_fields.json"), form_fields)
|
||||||
|
print(f" {len(form_fields.get('fields', []))} form fields saved.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" WARNING: Could not fetch form fields: {e}")
|
||||||
|
save_json(os.path.join(out_dir, "form_fields.json"), {"error": str(e)})
|
||||||
|
|
||||||
|
docs = adobe_api_get(f"libraryDocuments/{template_id}/documents")
|
||||||
|
save_json(os.path.join(out_dir, "documents.json"), docs)
|
||||||
|
|
||||||
|
for doc in docs.get("documents", []):
|
||||||
|
doc_id = doc["id"]
|
||||||
|
doc_name = doc.get("name", doc_id)
|
||||||
|
if not doc_name.lower().endswith(".pdf"):
|
||||||
|
doc_name += ".pdf"
|
||||||
|
safe_name = safe_dirname(doc_name)
|
||||||
|
pdf_path = os.path.join(out_dir, safe_name)
|
||||||
|
try:
|
||||||
|
pdf_bytes = adobe_api_get_bytes(
|
||||||
|
f"libraryDocuments/{template_id}/documents/{doc_id}"
|
||||||
|
)
|
||||||
|
with open(pdf_path, "wb") as f:
|
||||||
|
f.write(pdf_bytes)
|
||||||
|
print(f" PDF saved ({len(pdf_bytes) // 1024}KB) → {safe_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" WARNING: Could not download PDF: {e}")
|
||||||
|
|
||||||
|
print(f" Done → downloads/{dir_name}/")
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_download(args):
|
||||||
|
os.makedirs(DOWNLOADS_DIR, exist_ok=True)
|
||||||
|
templates = fetch_template_list()
|
||||||
|
|
||||||
|
if not templates:
|
||||||
|
print("No templates found in the account.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Specific template name requested
|
||||||
|
if args.template_name:
|
||||||
|
matches = [t for t in templates if t["name"] == args.template_name]
|
||||||
|
if not matches:
|
||||||
|
print(f"ERROR: No template named '{args.template_name}' found.")
|
||||||
|
print("Run 'list' to see available templates.")
|
||||||
|
sys.exit(1)
|
||||||
|
if len(matches) > 1:
|
||||||
|
print(f" {len(matches)} templates named '{args.template_name}' — using most recently modified.")
|
||||||
|
target = max(matches, key=lambda t: t.get("modifiedDate", ""))
|
||||||
|
download_one(target)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --all or default (no name given)
|
||||||
|
print(f"Downloading all {len(templates)} template(s)...")
|
||||||
|
for t in templates:
|
||||||
|
download_one(t)
|
||||||
|
print(f"\nAll templates downloaded to: {os.path.abspath(DOWNLOADS_DIR)}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Adobe Sign library templates",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
subparsers.add_parser("list", help="List all templates in the account")
|
||||||
|
|
||||||
|
dl_parser = subparsers.add_parser("download", help="Download templates")
|
||||||
|
dl_parser.add_argument(
|
||||||
|
"template_name", nargs="?", default=None,
|
||||||
|
metavar="TEMPLATE_NAME",
|
||||||
|
help="Name of template to download. Omit (or use --all) to download all.",
|
||||||
|
)
|
||||||
|
dl_parser.add_argument(
|
||||||
|
"--all", dest="download_all", action="store_true",
|
||||||
|
help="Download all templates (default when no name is given)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "list":
|
||||||
|
cmd_list(args)
|
||||||
|
elif args.command == "download":
|
||||||
|
cmd_download(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
"""
|
||||||
|
generate_pdfs.py
|
||||||
|
----------------
|
||||||
|
Generates realistic sample PDFs for adobe-to-docusign migration testing.
|
||||||
|
|
||||||
|
Each PDF mirrors the form fields described in the matching *-formfields.json
|
||||||
|
so that tab positions map to visible labels on the document.
|
||||||
|
|
||||||
|
Adobe rect coordinates are top-left origin; DocuSign yPosition is bottom-left.
|
||||||
|
Formula: docusign_y = PAGE_HEIGHT - adobe_top - adobe_height
|
||||||
|
To place a *label* just above a field: label_y = PAGE_HEIGHT - adobe_top + 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
W, H = letter # 612 × 792 pt
|
||||||
|
|
||||||
|
SAMPLE_DIR = Path(__file__).parent.parent / "sample-templates"
|
||||||
|
SAMPLE_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def save_b64(pdf_path: Path) -> Path:
|
||||||
|
b64_path = pdf_path.with_suffix(".pdf.b64")
|
||||||
|
b64_path.write_text(base64.b64encode(pdf_path.read_bytes()).decode())
|
||||||
|
return b64_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: draw a labelled field box at an Adobe-style rect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def draw_field(c: canvas.Canvas, label: str, rect: dict, page_h: float = H,
|
||||||
|
field_hint: str = ""):
|
||||||
|
left = rect["left"]
|
||||||
|
top = rect["top"]
|
||||||
|
width = rect["width"]
|
||||||
|
height = rect["height"]
|
||||||
|
# Convert Adobe top-origin to ReportLab bottom-origin
|
||||||
|
rl_y = page_h - top - height
|
||||||
|
|
||||||
|
# Label just above the box
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawString(left, rl_y + height + 3, label + (" [" + field_hint + "]" if field_hint else ":"))
|
||||||
|
|
||||||
|
# Field box
|
||||||
|
c.setStrokeColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.setFillColorRGB(0.97, 0.97, 1.0)
|
||||||
|
c.rect(left, rl_y, width, height, fill=1, stroke=1)
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_radio_option(c: canvas.Canvas, value: str, rect: dict, page_h: float = H):
|
||||||
|
left = rect["left"]
|
||||||
|
top = rect["top"]
|
||||||
|
size = min(rect["width"], rect["height"])
|
||||||
|
rl_y = page_h - top - size
|
||||||
|
c.setStrokeColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.setFillColorRGB(1, 1, 1)
|
||||||
|
c.circle(left + size / 2, rl_y + size / 2, size / 2, fill=1, stroke=1)
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
c.drawString(left + size + 3, rl_y + 1, value)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_checkbox(c: canvas.Canvas, label: str, rect: dict, page_h: float = H):
|
||||||
|
left = rect["left"]
|
||||||
|
top = rect["top"]
|
||||||
|
size = min(rect["width"], rect["height"])
|
||||||
|
rl_y = page_h - top - size
|
||||||
|
c.setStrokeColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.setFillColorRGB(1, 1, 1)
|
||||||
|
c.rect(left, rl_y, size, size, fill=1, stroke=1)
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
c.drawString(left + size + 5, rl_y + 1, label)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_signature_block(c: canvas.Canvas, label: str, rect: dict, page_h: float = H):
|
||||||
|
left = rect["left"]
|
||||||
|
top = rect["top"]
|
||||||
|
width = rect["width"]
|
||||||
|
height = rect["height"]
|
||||||
|
rl_y = page_h - top - height
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawString(left, rl_y + height + 3, label + ":")
|
||||||
|
c.setStrokeColorRGB(0.2, 0.4, 0.8)
|
||||||
|
c.setFillColorRGB(0.93, 0.96, 1.0)
|
||||||
|
c.rect(left, rl_y, width, height, fill=1, stroke=1)
|
||||||
|
c.setFillColorRGB(0.2, 0.4, 0.8)
|
||||||
|
c.setFont("Helvetica-Oblique", 8)
|
||||||
|
c.drawString(left + 4, rl_y + 4, "Sign here")
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def section_header(c: canvas.Canvas, text: str, y: float):
|
||||||
|
c.setFont("Helvetica-Bold", 11)
|
||||||
|
c.setFillColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.drawString(72, y, text)
|
||||||
|
c.setStrokeColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.line(72, y - 3, W - 72, y - 3)
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. Employee Onboarding Form (2 pages)
|
||||||
|
# ===========================================================================
|
||||||
|
def generate_onboarding():
|
||||||
|
path = SAMPLE_DIR / "onboarding-sample.pdf"
|
||||||
|
c = canvas.Canvas(str(path), pagesize=letter)
|
||||||
|
|
||||||
|
# ---- Page 1: Employee fills ----
|
||||||
|
c.setFont("Helvetica-Bold", 18)
|
||||||
|
c.setFillColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.drawCentredString(W / 2, H - 60, "Employee Onboarding Form")
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.setFillColorRGB(0.4, 0.4, 0.4)
|
||||||
|
c.drawCentredString(W / 2, H - 78, "Please complete all required fields before your first day.")
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
section_header(c, "Personal Information", H - 110)
|
||||||
|
|
||||||
|
fields_page1 = json.loads((SAMPLE_DIR / "onboarding-template-formfields.json").read_text())
|
||||||
|
|
||||||
|
for f in fields_page1:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 1:
|
||||||
|
continue
|
||||||
|
rect = locs[0]["rect"]
|
||||||
|
ft = f["type"]
|
||||||
|
label = f["fieldName"].replace("Employee", "Employee ").strip()
|
||||||
|
|
||||||
|
if ft == "TEXT_FIELD":
|
||||||
|
draw_field(c, label, rect)
|
||||||
|
elif ft == "DATE":
|
||||||
|
draw_field(c, label, rect, field_hint="MM/DD/YYYY")
|
||||||
|
elif ft == "DROPDOWN":
|
||||||
|
draw_field(c, label, rect, field_hint=", ".join(f.get("items", [])))
|
||||||
|
elif ft == "CHECKBOX":
|
||||||
|
draw_checkbox(c, "Health & Dental Benefits", rect)
|
||||||
|
elif ft == "RADIO":
|
||||||
|
# Draw label above first radio
|
||||||
|
if locs:
|
||||||
|
first_rect = locs[0]["rect"]
|
||||||
|
rl_y = H - first_rect["top"] - first_rect["height"]
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawString(first_rect["left"], rl_y + first_rect["height"] + 3, "Commute Option:")
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
items = f.get("items", [])
|
||||||
|
for i, loc in enumerate(locs):
|
||||||
|
val = items[i] if i < len(items) else str(i)
|
||||||
|
draw_radio_option(c, val, loc["rect"])
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 1 of 2 — Employee Onboarding Form")
|
||||||
|
c.showPage()
|
||||||
|
|
||||||
|
# ---- Page 2: HR section + signatures ----
|
||||||
|
c.setFont("Helvetica-Bold", 14)
|
||||||
|
c.setFillColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.drawCentredString(W / 2, H - 50, "Employee Onboarding Form — Signatures")
|
||||||
|
c.setFillColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
section_header(c, "HR Notes (Internal)", H - 90)
|
||||||
|
|
||||||
|
for f in fields_page1:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 2:
|
||||||
|
continue
|
||||||
|
rect = locs[0]["rect"]
|
||||||
|
ft = f["type"]
|
||||||
|
if ft == "TEXT_FIELD":
|
||||||
|
draw_field(c, f["fieldName"], rect, field_hint="HR use only")
|
||||||
|
elif ft == "SIGNATURE":
|
||||||
|
draw_signature_block(c, f["fieldName"], rect)
|
||||||
|
|
||||||
|
section_header(c, "Acknowledgement", H - 260)
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.2, 0.2, 0.2)
|
||||||
|
disclaimer = (
|
||||||
|
"By signing below, the employee confirms that all information provided is accurate "
|
||||||
|
"and that they have read and understood the company policies. HR Representative "
|
||||||
|
"countersigns to approve onboarding completion."
|
||||||
|
)
|
||||||
|
text = c.beginText(72, H - 280)
|
||||||
|
text.setFont("Helvetica", 9)
|
||||||
|
text.setFillColor((0.2, 0.2, 0.2))
|
||||||
|
# Simple word wrap
|
||||||
|
words = disclaimer.split()
|
||||||
|
line, lines = [], []
|
||||||
|
for w in words:
|
||||||
|
line.append(w)
|
||||||
|
if len(" ".join(line)) > 72:
|
||||||
|
lines.append(" ".join(line[:-1]))
|
||||||
|
line = [w]
|
||||||
|
if line:
|
||||||
|
lines.append(" ".join(line))
|
||||||
|
for ln in lines:
|
||||||
|
c.drawString(72, text.getY(), ln)
|
||||||
|
text.moveCursor(0, 12)
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 2 of 2 — Employee Onboarding Form")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
b64 = save_b64(path)
|
||||||
|
print(f"✅ Generated {path.name} + {b64.name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. NDA Template (2 pages)
|
||||||
|
# ===========================================================================
|
||||||
|
def generate_nda():
|
||||||
|
path = SAMPLE_DIR / "nda-sample.pdf"
|
||||||
|
c = canvas.Canvas(str(path), pagesize=letter)
|
||||||
|
|
||||||
|
# ---- Page 1: Agreement text ----
|
||||||
|
c.setFont("Helvetica-Bold", 18)
|
||||||
|
c.setFillColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.drawCentredString(W / 2, H - 60, "Non-Disclosure Agreement")
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawCentredString(W / 2, H - 80, "Confidential — Between Employee and Company")
|
||||||
|
|
||||||
|
section_header(c, "Agreement Parties", H - 110)
|
||||||
|
|
||||||
|
# EmployeeName field (page 1, top=220)
|
||||||
|
fields = json.loads((SAMPLE_DIR / "nda-template-formfields.json").read_text())
|
||||||
|
for f in fields:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 1:
|
||||||
|
continue
|
||||||
|
rect = locs[0]["rect"]
|
||||||
|
draw_field(c, "Employee Full Name", rect)
|
||||||
|
|
||||||
|
# Boilerplate body text
|
||||||
|
body = [
|
||||||
|
"This Non-Disclosure Agreement ('Agreement') is entered into as of the date signed",
|
||||||
|
"below ('Effective Date') by and between the Employee identified above and Acme Corp",
|
||||||
|
"('Company'), collectively referred to as the 'Parties'.",
|
||||||
|
"",
|
||||||
|
"1. CONFIDENTIAL INFORMATION",
|
||||||
|
" The Employee agrees not to disclose, publish, or make available any Confidential",
|
||||||
|
" Information (as defined herein) to any third party without the prior written",
|
||||||
|
" consent of the Company.",
|
||||||
|
"",
|
||||||
|
"2. OBLIGATIONS",
|
||||||
|
" The Employee shall use the Confidential Information solely for the purpose of",
|
||||||
|
" performing their duties with the Company and shall protect the information with",
|
||||||
|
" at least the same degree of care used for their own confidential information.",
|
||||||
|
"",
|
||||||
|
"3. TERM",
|
||||||
|
" This Agreement shall remain in effect for a period of two (2) years from the",
|
||||||
|
" Effective Date and shall survive termination of employment.",
|
||||||
|
"",
|
||||||
|
"4. GOVERNING LAW",
|
||||||
|
" This Agreement shall be governed by the laws of the State of California.",
|
||||||
|
]
|
||||||
|
y = H - 260
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.15, 0.15, 0.15)
|
||||||
|
for line in body:
|
||||||
|
c.drawString(72, y, line)
|
||||||
|
y -= 13
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 1 of 2 — Non-Disclosure Agreement")
|
||||||
|
c.showPage()
|
||||||
|
|
||||||
|
# ---- Page 2: Signature ----
|
||||||
|
c.setFont("Helvetica-Bold", 14)
|
||||||
|
c.setFillColorRGB(0.1, 0.2, 0.5)
|
||||||
|
c.drawCentredString(W / 2, H - 50, "Non-Disclosure Agreement — Execution Page")
|
||||||
|
|
||||||
|
section_header(c, "Signatures", H - 100)
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.setFillColorRGB(0.2, 0.2, 0.2)
|
||||||
|
c.drawString(72, H - 130, "By signing, the Employee acknowledges reading and agreeing to all terms above.")
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 2:
|
||||||
|
continue
|
||||||
|
draw_signature_block(c, "Employee Signature", locs[0]["rect"])
|
||||||
|
|
||||||
|
# Date line
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawString(72, H - 500, "Date: _______________________________")
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 2 of 2 — Non-Disclosure Agreement")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
b64 = save_b64(path)
|
||||||
|
print(f"✅ Generated {path.name} + {b64.name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. Sales Agreement (3 pages)
|
||||||
|
# ===========================================================================
|
||||||
|
def generate_sales_contract():
|
||||||
|
path = SAMPLE_DIR / "sales-contract-sample.pdf"
|
||||||
|
c = canvas.Canvas(str(path), pagesize=letter)
|
||||||
|
|
||||||
|
fields = json.loads((SAMPLE_DIR / "sales-contract-formfields.json").read_text())
|
||||||
|
|
||||||
|
# ---- Page 1: Terms + PurchasePrice field ----
|
||||||
|
c.setFont("Helvetica-Bold", 18)
|
||||||
|
c.setFillColorRGB(0.1, 0.25, 0.1)
|
||||||
|
c.drawCentredString(W / 2, H - 60, "Sales Agreement")
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawCentredString(W / 2, H - 78, "Buyer and Seller — Binding Contract")
|
||||||
|
|
||||||
|
section_header(c, "Transaction Details", H - 110)
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 1:
|
||||||
|
continue
|
||||||
|
rect = locs[0]["rect"]
|
||||||
|
draw_field(c, "Purchase Price (USD)", rect, field_hint="e.g. 5000.00")
|
||||||
|
|
||||||
|
body_p1 = [
|
||||||
|
"This Sales Agreement ('Agreement') is entered into as of the date of execution",
|
||||||
|
"by and between the Buyer and the Seller identified on the signature page.",
|
||||||
|
"",
|
||||||
|
"1. SALE OF GOODS",
|
||||||
|
" Seller agrees to sell and Buyer agrees to purchase the goods described in",
|
||||||
|
" Schedule A (attached) for the Purchase Price stated above.",
|
||||||
|
"",
|
||||||
|
"2. PAYMENT TERMS",
|
||||||
|
" Payment is due in full within 30 days of signing unless otherwise agreed",
|
||||||
|
" in a separate written addendum signed by both parties.",
|
||||||
|
"",
|
||||||
|
"3. DELIVERY",
|
||||||
|
" Seller shall deliver the goods within 14 days of receipt of payment.",
|
||||||
|
" Risk of loss transfers to Buyer upon delivery.",
|
||||||
|
"",
|
||||||
|
"4. WARRANTIES",
|
||||||
|
" Seller warrants that it has clear title to the goods and that the goods",
|
||||||
|
" conform to the specifications in Schedule A.",
|
||||||
|
"",
|
||||||
|
"5. DISPUTE RESOLUTION",
|
||||||
|
" Disputes shall be resolved by binding arbitration under AAA Commercial Rules.",
|
||||||
|
]
|
||||||
|
y = H - 210
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.15, 0.15, 0.15)
|
||||||
|
for line in body_p1:
|
||||||
|
c.drawString(72, y, line)
|
||||||
|
y -= 13
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 1 of 3 — Sales Agreement")
|
||||||
|
c.showPage()
|
||||||
|
|
||||||
|
# ---- Page 2: Schedule A ----
|
||||||
|
c.setFont("Helvetica-Bold", 14)
|
||||||
|
c.setFillColorRGB(0.1, 0.25, 0.1)
|
||||||
|
c.drawCentredString(W / 2, H - 50, "Schedule A — Goods Description")
|
||||||
|
section_header(c, "Item Detail", H - 90)
|
||||||
|
body_p2 = [
|
||||||
|
"Item: [To be completed by Seller]",
|
||||||
|
"Quantity: [To be completed by Seller]",
|
||||||
|
"Description: [To be completed by Seller]",
|
||||||
|
"Unit Price: [To be completed by Seller]",
|
||||||
|
"",
|
||||||
|
"Condition: New / Used / Refurbished (circle one)",
|
||||||
|
"",
|
||||||
|
"Special Terms:",
|
||||||
|
" _________________________________________________________",
|
||||||
|
" _________________________________________________________",
|
||||||
|
" _________________________________________________________",
|
||||||
|
]
|
||||||
|
y = H - 130
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.setFillColorRGB(0.15, 0.15, 0.15)
|
||||||
|
for line in body_p2:
|
||||||
|
c.drawString(72, y, line)
|
||||||
|
y -= 18
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 2 of 3 — Sales Agreement")
|
||||||
|
c.showPage()
|
||||||
|
|
||||||
|
# ---- Page 3: Signatures ----
|
||||||
|
c.setFont("Helvetica-Bold", 14)
|
||||||
|
c.setFillColorRGB(0.1, 0.25, 0.1)
|
||||||
|
c.drawCentredString(W / 2, H - 50, "Sales Agreement — Execution Page")
|
||||||
|
section_header(c, "Authorized Signatures", H - 90)
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.2, 0.2, 0.2)
|
||||||
|
c.drawString(72, H - 120, "Each party signing below agrees to be bound by all terms and conditions of this Agreement.")
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
locs = f.get("locations", [])
|
||||||
|
if not locs or locs[0]["pageNumber"] != 3:
|
||||||
|
continue
|
||||||
|
label = "Buyer Signature" if f["fieldName"] == "BuyerSign" else "Seller Signature"
|
||||||
|
draw_signature_block(c, label, locs[0]["rect"])
|
||||||
|
|
||||||
|
# Printed name / date lines
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColorRGB(0.3, 0.3, 0.3)
|
||||||
|
c.drawString(200, H - 440, "Printed Name: ________________________ Date: __________")
|
||||||
|
c.drawString(420, H - 440, "Printed Name: ________________________ Date: __________")
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColorRGB(0.5, 0.5, 0.5)
|
||||||
|
c.drawCentredString(W / 2, 30, "Page 3 of 3 — Sales Agreement")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
b64 = save_b64(path)
|
||||||
|
print(f"✅ Generated {path.name} + {b64.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generate_onboarding()
|
||||||
|
generate_nda()
|
||||||
|
generate_sales_contract()
|
||||||
|
print("\nAll sample PDFs generated.")
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""
|
||||||
|
migrate_template.py
|
||||||
|
-------------------
|
||||||
|
End-to-end migration: downloads an Adobe Sign template, converts it to
|
||||||
|
DocuSign format, and uploads it to DocuSign.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 src/migrate_template.py --list
|
||||||
|
Show all Adobe Sign templates available to download.
|
||||||
|
|
||||||
|
python3 src/migrate_template.py --template "Template Name"
|
||||||
|
Download, convert, and upload the named template.
|
||||||
|
If multiple templates share the same name, the most recently modified
|
||||||
|
one is used.
|
||||||
|
|
||||||
|
python3 src/migrate_template.py --template "Template Name" --skip-upload
|
||||||
|
Download and convert only — writes the DocuSign JSON to
|
||||||
|
migration-output/ without uploading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from adobe_api import adobe_api_get, adobe_api_get_bytes
|
||||||
|
from compose_docusign_template import compose_template
|
||||||
|
from upload_docusign_template import upload_template
|
||||||
|
|
||||||
|
DOWNLOADS_DIR = Path(__file__).parent.parent / "downloads"
|
||||||
|
OUTPUT_DIR = Path(__file__).parent.parent / "migration-output"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_dirname(name):
|
||||||
|
return "".join(c if c.isalnum() or c in " -_" else "_" for c in name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
import json
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_template_list():
|
||||||
|
result = adobe_api_get("libraryDocuments")
|
||||||
|
return result.get("libraryDocumentList", [])
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list():
|
||||||
|
print("Fetching template list from Adobe Sign...")
|
||||||
|
templates = fetch_template_list()
|
||||||
|
if not templates:
|
||||||
|
print("No templates found.")
|
||||||
|
return
|
||||||
|
print(f"\n{'Name':<45} {'Modified':<25} {'ID'}")
|
||||||
|
print("-" * 100)
|
||||||
|
for t in sorted(templates, key=lambda x: x.get("modifiedDate", ""), reverse=True):
|
||||||
|
print(f"{t['name']:<45} {t.get('modifiedDate', 'n/a'):<25} {t['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_template(template_name, templates):
|
||||||
|
matches = [t for t in templates if t["name"] == template_name]
|
||||||
|
if not matches:
|
||||||
|
print(f"ERROR: No template named '{template_name}' found in Adobe Sign.")
|
||||||
|
print("Run --list to see available templates.")
|
||||||
|
sys.exit(1)
|
||||||
|
if len(matches) > 1:
|
||||||
|
print(f" {len(matches)} templates named '{template_name}' — using most recently modified.")
|
||||||
|
return max(matches, key=lambda t: t.get("modifiedDate", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def download_template(template) -> Path:
|
||||||
|
import json
|
||||||
|
template_id = template["id"]
|
||||||
|
template_name = template["name"]
|
||||||
|
dir_name = f"{safe_dirname(template_name)}__{template_id[:8]}"
|
||||||
|
out_dir = DOWNLOADS_DIR / dir_name
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"\nDownloading '{template_name}' → downloads/{dir_name}/")
|
||||||
|
|
||||||
|
metadata = adobe_api_get(f"libraryDocuments/{template_id}")
|
||||||
|
save_json(out_dir / "metadata.json", metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
form_fields = adobe_api_get(f"libraryDocuments/{template_id}/formFields")
|
||||||
|
save_json(out_dir / "form_fields.json", form_fields)
|
||||||
|
field_count = len(form_fields.get("fields", []))
|
||||||
|
print(f" {field_count} form fields")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" WARNING: Could not fetch form fields: {e}")
|
||||||
|
save_json(out_dir / "form_fields.json", {"error": str(e)})
|
||||||
|
|
||||||
|
docs = adobe_api_get(f"libraryDocuments/{template_id}/documents")
|
||||||
|
save_json(out_dir / "documents.json", docs)
|
||||||
|
|
||||||
|
for doc in docs.get("documents", []):
|
||||||
|
doc_id = doc["id"]
|
||||||
|
doc_name = doc.get("name", doc_id)
|
||||||
|
if not doc_name.lower().endswith(".pdf"):
|
||||||
|
doc_name += ".pdf"
|
||||||
|
safe_name = safe_dirname(doc_name)
|
||||||
|
try:
|
||||||
|
pdf_bytes = adobe_api_get_bytes(
|
||||||
|
f"libraryDocuments/{template_id}/documents/{doc_id}"
|
||||||
|
)
|
||||||
|
with open(out_dir / safe_name, "wb") as f:
|
||||||
|
f.write(pdf_bytes)
|
||||||
|
print(f" PDF ({len(pdf_bytes) // 1024}KB) → {safe_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" WARNING: Could not download PDF: {e}")
|
||||||
|
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def convert_template(template_dir: Path) -> Path:
|
||||||
|
output_path = OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
||||||
|
print(f"\nConverting to DocuSign format...")
|
||||||
|
_, warnings = compose_template(str(template_dir), str(output_path))
|
||||||
|
print(f" Written: {output_path}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" WARNING: {w}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate an Adobe Sign template to DocuSign",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument(
|
||||||
|
"--template", metavar="NAME",
|
||||||
|
help="Name of the Adobe Sign template to migrate"
|
||||||
|
)
|
||||||
|
group.add_argument(
|
||||||
|
"--list", action="store_true",
|
||||||
|
help="List available Adobe Sign templates"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-upload", action="store_true",
|
||||||
|
help="Convert only — do not upload to DocuSign"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
DOWNLOADS_DIR.mkdir(exist_ok=True)
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
cmd_list()
|
||||||
|
return
|
||||||
|
|
||||||
|
templates = fetch_template_list()
|
||||||
|
template = find_template(args.template, templates)
|
||||||
|
template_dir = download_template(template)
|
||||||
|
output_path = convert_template(template_dir)
|
||||||
|
|
||||||
|
if args.skip_upload:
|
||||||
|
print(f"\nSkipped upload. DocuSign JSON: {output_path}")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
upload_template(str(output_path))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Mapping Function Unit Test
|
||||||
|
"""
|
||||||
|
Unit test harness for mapping Adobe Sign form fields to DocuSign tab config.
|
||||||
|
Validates that compose_docusign_template.py produces correctly structured output:
|
||||||
|
- Tabs are grouped into typed arrays (textTabs, signHereTabs, etc.)
|
||||||
|
- required / locked are strings "true" / "false", not booleans
|
||||||
|
- listTabs have listItems: [{text, value}], not a string array
|
||||||
|
- radioGroupTabs have groupName + radios[]
|
||||||
|
- No top-level "status" field
|
||||||
|
- No email/name on signer role placeholders
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
# Support running from src/ or project root
|
||||||
|
BASE = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from compose_docusign_template import compose_template
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_mapping():
|
||||||
|
output_path = BASE / "validation" / "compose-doc-template-complete.json"
|
||||||
|
compose_template(
|
||||||
|
fields_path=str(BASE / "sample-templates" / "onboarding-template-formfields.json"),
|
||||||
|
template_meta_path=str(BASE / "sample-templates" / "onboarding-template.json"),
|
||||||
|
pdf_b64_path=str(BASE / "sample-templates" / "onboarding-sample.pdf.b64"),
|
||||||
|
output_path=str(output_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
template = json.loads(output_path.read_text())
|
||||||
|
|
||||||
|
# -- No top-level status field --
|
||||||
|
assert "status" not in template, "Template must not have a top-level 'status' field"
|
||||||
|
|
||||||
|
signers = template["recipients"]["signers"]
|
||||||
|
assert len(signers) == 2, f"Expected 2 signers, got {len(signers)}"
|
||||||
|
|
||||||
|
signer0_tabs = signers[0]["tabs"]
|
||||||
|
signer1_tabs = signers[1]["tabs"]
|
||||||
|
|
||||||
|
# -- No email/name on role placeholders --
|
||||||
|
for s in signers:
|
||||||
|
assert "email" not in s, f"Signer should not have 'email' in template role: {s['roleName']}"
|
||||||
|
assert "name" not in s, f"Signer should not have 'name' in template role: {s['roleName']}"
|
||||||
|
|
||||||
|
# -- Tab groups present and properly typed --
|
||||||
|
assert "textTabs" in signer0_tabs, "Signer 0 missing textTabs"
|
||||||
|
assert "dateSignedTabs" in signer0_tabs, "Signer 0 missing dateSignedTabs"
|
||||||
|
assert "listTabs" in signer0_tabs, "Signer 0 missing listTabs"
|
||||||
|
assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs"
|
||||||
|
assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs"
|
||||||
|
assert "signHereTabs" in signer0_tabs, "Signer 0 missing signHereTabs"
|
||||||
|
assert "textTabs" in signer1_tabs, "Signer 1 missing textTabs"
|
||||||
|
assert "signHereTabs" in signer1_tabs, "Signer 1 missing signHereTabs"
|
||||||
|
|
||||||
|
# -- required / locked are strings --
|
||||||
|
for tab in signer0_tabs.get("textTabs", []):
|
||||||
|
assert isinstance(tab.get("required"), str), f"required must be string, got {type(tab.get('required'))}"
|
||||||
|
for tab in signer0_tabs.get("listTabs", []):
|
||||||
|
assert isinstance(tab.get("required"), str), f"required must be string on listTab"
|
||||||
|
|
||||||
|
# -- listItems are objects --
|
||||||
|
list_tab = signer0_tabs["listTabs"][0]
|
||||||
|
assert "listItems" in list_tab, "listTab missing listItems"
|
||||||
|
assert isinstance(list_tab["listItems"][0], dict), "listItems entries must be {text, value} dicts"
|
||||||
|
assert "text" in list_tab["listItems"][0], "listItems entries must have 'text'"
|
||||||
|
assert "value" in list_tab["listItems"][0], "listItems entries must have 'value'"
|
||||||
|
|
||||||
|
# -- radioGroupTabs structure --
|
||||||
|
radio_tab = signer0_tabs["radioGroupTabs"][0]
|
||||||
|
assert "groupName" in radio_tab, "radioGroupTab missing groupName"
|
||||||
|
assert "radios" in radio_tab, "radioGroupTab missing radios"
|
||||||
|
assert len(radio_tab["radios"]) == 3, f"Expected 3 radios, got {len(radio_tab['radios'])}"
|
||||||
|
for r in radio_tab["radios"]:
|
||||||
|
assert "pageNumber" in r, "radio missing pageNumber"
|
||||||
|
assert "xPosition" in r, "radio missing xPosition"
|
||||||
|
assert "yPosition" in r, "radio missing yPosition"
|
||||||
|
assert "value" in r, "radio missing value"
|
||||||
|
|
||||||
|
# -- All tabs have required placement fields --
|
||||||
|
all_single_tabs = (
|
||||||
|
signer0_tabs.get("textTabs", [])
|
||||||
|
+ signer0_tabs.get("dateSignedTabs", [])
|
||||||
|
+ signer0_tabs.get("signHereTabs", [])
|
||||||
|
+ signer0_tabs.get("listTabs", [])
|
||||||
|
+ signer0_tabs.get("checkboxTabs", [])
|
||||||
|
+ signer1_tabs.get("textTabs", [])
|
||||||
|
+ signer1_tabs.get("signHereTabs", [])
|
||||||
|
)
|
||||||
|
for tab in all_single_tabs:
|
||||||
|
for field in ("documentId", "pageNumber", "xPosition", "yPosition"):
|
||||||
|
assert field in tab, f"Tab '{tab.get('tabLabel')}' missing '{field}'"
|
||||||
|
|
||||||
|
print("✅ All mapping assertions passed!")
|
||||||
|
print("\n--- Generated template (recipients section) ---")
|
||||||
|
pprint(template["recipients"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_onboarding_mapping()
|
||||||
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""
|
||||||
|
upload_docusign_template.py
|
||||||
|
---------------------------
|
||||||
|
Uploads a DocuSign template JSON file to DocuSign via the REST API.
|
||||||
|
Authenticates using JWT grant (no Node.js dependency required).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 src/upload_docusign_template.py --file migration-output/<name>/docusign-template.json
|
||||||
|
|
||||||
|
First-time setup:
|
||||||
|
python3 src/docusign_auth.py --consent # grant consent once
|
||||||
|
python3 src/upload_docusign_template.py --file <path>
|
||||||
|
|
||||||
|
Required .env keys (see docusign_auth.py for full list):
|
||||||
|
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
|
||||||
|
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from docusign_auth import get_access_token
|
||||||
|
|
||||||
|
|
||||||
|
def upload_template(file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
POST a template JSON file to the DocuSign Templates API.
|
||||||
|
Returns the created templateId.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"ERROR: File not found: {file_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(file_path) as f:
|
||||||
|
template = json.load(f)
|
||||||
|
|
||||||
|
account_id = os.getenv("DOCUSIGN_ACCOUNT_ID")
|
||||||
|
base_url = os.getenv("DOCUSIGN_BASE_URL", "https://demo.docusign.net/restapi")
|
||||||
|
|
||||||
|
if not account_id:
|
||||||
|
print("ERROR: DOCUSIGN_ACCOUNT_ID must be set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = get_access_token()
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{base_url}/v2.1/accounts/{account_id}/templates"
|
||||||
|
print(f"Uploading '{template.get('name', file_path)}' to DocuSign...")
|
||||||
|
|
||||||
|
resp = requests.post(url, headers=headers, json=template)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
# Token may have just expired — clear cache and retry once
|
||||||
|
os.environ.pop("DOCUSIGN_ACCESS_TOKEN", None)
|
||||||
|
os.environ.pop("DOCUSIGN_TOKEN_EXPIRY", None)
|
||||||
|
token = get_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = requests.post(url, headers=headers, json=template)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
print(f"ERROR: Upload failed ({resp.status_code})")
|
||||||
|
print(resp.text)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
template_id = result.get("templateId")
|
||||||
|
print(f"Template created: {template_id}")
|
||||||
|
return template_id
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Upload a DocuSign template JSON to your DocuSign account"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file", required=True,
|
||||||
|
help="Path to the docusign-template.json file to upload"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
upload_template(args.file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
## Edge Cases Copied for Regression
|
||||||
|
|
||||||
|
- Onboarding template (all core types, static)
|
||||||
|
- NDA template
|
||||||
|
- Sales contract template
|
||||||
|
|
||||||
|
Copy future discovered edge case templates here for mapping, regression, and unit test reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Updated automatically each migration/test run. Cc: agent orchestrator*
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Field Type Regression Checklist
|
||||||
|
|
||||||
|
- [x] TEXT_FIELD — EmployeeName, HRNotes
|
||||||
|
- [x] CHECKBOX — Benefits
|
||||||
|
- [x] RADIO — CommuteOption
|
||||||
|
- [x] DROPDOWN — Position
|
||||||
|
- [x] DATE — StartDate
|
||||||
|
- [x] SIGNATURE — EmployeeSignature, HRSignature
|
||||||
|
|
||||||
|
Fields exercised by onboarding sample; extend with new fields as more templates are tested.
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Known Bugs, Platform Quirks & System Notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DocuSign API Bugs
|
||||||
|
|
||||||
|
### `numberTabs` renders as text field with Numbers validation (2026-04-15)
|
||||||
|
**Status:** Confirmed DocuSign API bug
|
||||||
|
**Symptom:** When a template is created via API with a `numberTabs` entry, DocuSign
|
||||||
|
renders it in the template editor as a plain Text field with "Numbers" validation
|
||||||
|
applied — not as a native Number tab type. The JSON sent to the API is correct
|
||||||
|
(`numberTabs`); the mismatch is in how DocuSign stores or interprets it server-side.
|
||||||
|
**Impact:** Visual/semantic only. The field still enforces numeric input at signing
|
||||||
|
time. Tab merging and formula references may behave differently than a true Number tab.
|
||||||
|
**Workaround:** None known via API. Can be corrected manually in the DocuSign template
|
||||||
|
editor after upload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adobe Sign API Quirks
|
||||||
|
|
||||||
|
### Company/Title contentType returned with `SIGNER_` prefix (2026-04-15)
|
||||||
|
**Symptom:** When Company and Title fields are set via the Adobe Sign UI (the API
|
||||||
|
rejects `COMPANY`/`TITLE` as contentType values), the API returns them as
|
||||||
|
`SIGNER_COMPANY` and `SIGNER_TITLE` respectively.
|
||||||
|
**Fix applied:** `compose_docusign_template.py` accepts both variants:
|
||||||
|
`content_type in ("COMPANY", "SIGNER_COMPANY")` and `("TITLE", "SIGNER_TITLE")`.
|
||||||
|
|
||||||
|
### Multi-location (cloned) fields — only first location used (2026-04-15)
|
||||||
|
**Symptom:** Adobe Sign allows a single field definition to have multiple `locations`
|
||||||
|
(e.g. Drop-down 1 appeared twice on the page, synced). The original compose script
|
||||||
|
only used `locations[0]`, silently dropping all subsequent instances.
|
||||||
|
**Fix applied:** `compose_docusign_template.py` now emits one tab per location for
|
||||||
|
all data-entry tab types. DocuSign replicates Adobe Sign's cloned-field sync behavior
|
||||||
|
via tab merging: tabs sharing the same `tabLabel` auto-sync at signing time.
|
||||||
|
**Tab types covered by fix:** `textTabs`, `numberTabs`, `dateTabs`, `dateSignedTabs`,
|
||||||
|
`fullNameTabs`, `emailAddressTabs`, `companyTabs`, `titleTabs`, `listTabs`,
|
||||||
|
`checkboxTabs`.
|
||||||
|
**Not applicable to:** `radioGroupTabs` (each location is a radio button, not a
|
||||||
|
clone), `signHereTabs` / `initialHereTabs` (each location is an independent signing
|
||||||
|
action), `signerAttachmentTabs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Pipeline Bugs Fixed
|
||||||
|
|
||||||
|
### Text fields rendered as vertical lines (zero width) (2026-04-15)
|
||||||
|
**Symptom:** All text-entry tabs in DocuSign appeared as a thin vertical line rather
|
||||||
|
than a visible input box.
|
||||||
|
**Root cause:** `loc_to_docusign()` was dropping the `width` and `height` values from
|
||||||
|
the Adobe Sign location dict. DocuSign rendered tabs with no width.
|
||||||
|
**Fix applied:** `loc_to_docusign()` now returns `(page, x, y, width, height)`.
|
||||||
|
`width` and `height` are included on all sized tabs. A `MIN_TEXT_WIDTH = 120`
|
||||||
|
constant ensures fields are at least ~15 characters wide even if the source was
|
||||||
|
narrower.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-existing Notes
|
||||||
|
|
||||||
|
- DocuSign radio tabs sometimes display out of order when group is missing name (2026-04-14, regression found)
|
||||||
|
- Some PDFs import with negative/zero field widths (caught in onboarding mapping test, 2026-04-14)
|
||||||
|
- API rate limits: Adobe test sandbox can return 429 if >10 requests/sec (avoid in integration tests)
|
||||||
|
- DocuSign account- vs user-level templates: admin-only API tokens needed for bulk tests
|
||||||
|
- DocuSign list tabs with >99 items currently fail to render (API limitation as of 2026)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Testing Scenarios & Edge Cases
|
||||||
|
|
||||||
|
Maintain this log of all scenarios and edge cases identified as important for the Adobe→DocuSign migration project. Refer to it for regression testing and future QA automation.
|
||||||
|
|
||||||
|
## Basic Templates
|
||||||
|
- NDA (text, signature) — base case
|
||||||
|
- Sales agreement (multi-signer, text, signature)
|
||||||
|
|
||||||
|
## Comprehensive Field Types (from onboarding form)
|
||||||
|
|
||||||
|
For each case, note expected vs actual outcome and gotchas for migration.
|
||||||
|
- TEXT_FIELD (required, optional)
|
||||||
|
- CHECKBOX
|
||||||
|
- RADIO (radioGroup mapping)
|
||||||
|
- DROPDOWN (list mapping, test all items)
|
||||||
|
- DATE (with/without format)
|
||||||
|
- SIGNATURE
|
||||||
|
- Read-only/info field (for HR/approver)
|
||||||
|
- Conditional fields (shown/hidden by logic)
|
||||||
|
|
||||||
|
## Challenging Cases
|
||||||
|
- Grouped checkboxes with same recipient
|
||||||
|
- Radios with missing/duplicate group names
|
||||||
|
- Fields for multiple roles (employee vs HR)
|
||||||
|
- Required/optional combinations
|
||||||
|
- Complex conditional logic (e.g. show only if X == 'yes')
|
||||||
|
- Advanced validation (masks, value limits)
|
||||||
|
- PDFs with overlapping field rectangles
|
||||||
|
- Edge API payloads from real customers (anonymized)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For each new field-type/logic combo encountered, add data and result here. Update after each mapping/test run.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Compose DocuSign Template JSON
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Combine mapped fields, recipients, and a base document into a complete DocuSign template JSON payload suitable for the esign-direct/SDK API.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Minimal driver script: `src/compose_docusign_template.py` will:
|
||||||
|
- Load Adobe recipient and field objects (from onboarding sample)
|
||||||
|
- Create DocuSign template payload: name, recipients, doc ref (simulated), and tabs per signer
|
||||||
|
- Logs to validation/ as evidence
|
||||||
|
|
||||||
|
*To run next: python3 src/compose_docusign_template.py (generated by Cleo)*
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Conditional Logic Mapping Support
|
||||||
|
|
||||||
|
## Status: Not yet encountered in onboarding sample
|
||||||
|
|
||||||
|
- The onboarding sample does not contain conditional logic (all fields unconditional/static).
|
||||||
|
- Once a template with Adobe conditional logic is added (show/hide fields, rules), implement detection and mapping.
|
||||||
|
- Plan: Log first found conditional fields here for review and mapping strategy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Autonomous agent note: Revisit as edge cases accumulate.*
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Decision Log & Updates
|
||||||
|
|
||||||
|
## 2026-04-14 (Session complete)
|
||||||
|
- Automated, agentic migration ran end-to-end with dry-run, sample onboarding.
|
||||||
|
- All core field types extracted, mapped, stub-ingested with results written to validation/
|
||||||
|
- No ambiguous or lossy mapping found for onboarding template.
|
||||||
|
- Known bug/quirk, platform/edge case, and results logs updated.
|
||||||
|
- Board, mapping, and test docs updated inline with validation outputs.
|
||||||
|
- Architecture diagram added to docs/architecture.md (see that doc for mermaid block).
|
||||||
|
|
||||||
|
## Next session:
|
||||||
|
- Onboard additional real-world template JSONs for advanced Adobe logic, field masking, or validation.
|
||||||
|
- Integrate with live DocuSign sandbox API for ingest (replace stub).
|
||||||
|
|
||||||
|
*-- Cleo, Agent Orchestrator*
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# DocuSign Ingest Eval (Stub)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Demo ingest function pushes a mapped onboarding template to DocuSign. (For MVP, log payload instead of sending.)
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Mapped onboarding template converted to DocuSign tab format as planned.
|
||||||
|
- Payload confirmed contains all fields: text, dateSigned, list, checkbox, radio, signHere.
|
||||||
|
- No warnings or field skips seen.
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
Prepare full round-trip test flow (extract, map, ingest, validate) as MVP skeleton.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Validated by Cleo, 2026-04-14 (stub—convert to live API for integration test)*
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Mapping Ambiguity Log
|
||||||
|
|
||||||
|
## Current Runs:
|
||||||
|
- [2026-04-14] Onboarding template: No ambiguous fields detected in static test set.
|
||||||
|
- Future runs: As soon as a field maps to "unknown" or has non-1:1 properties, log here, with Adobe field json and what (if anything) was mapped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Update after every execution; required for completeness in automated migration!*
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Extraction Eval: Onboarding Template Field Handling
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Confirm presence and parse status for: CHECKBOX, RADIO, DROPDOWN, DATE field types.
|
||||||
|
|
||||||
|
## Fields Found
|
||||||
|
|
||||||
|
| FieldName | Type | Required | Extra Props |
|
||||||
|
|--------------------|------------|----------|----------------------|
|
||||||
|
| EmployeeName | TEXT_FIELD | True | |
|
||||||
|
| StartDate | DATE | True | |
|
||||||
|
| Position | DROPDOWN | True | items=[Manager,Engineer,Tech,HR] |
|
||||||
|
| Benefits | CHECKBOX | False | |
|
||||||
|
| CommuteOption | RADIO | False | items=[Car,Transit,Bike], group=CommuteGroup |
|
||||||
|
| HRNotes | TEXT_FIELD | False | readOnly |
|
||||||
|
| EmployeeSignature | SIGNATURE | True | |
|
||||||
|
| HRSignature | SIGNATURE | True | |
|
||||||
|
|
||||||
|
## Results
|
||||||
|
- ✅ **CHECKBOX present:** `Benefits`, correct type and parse.
|
||||||
|
- ✅ **RADIO present:** `CommuteOption` (with group/items, correct parse).
|
||||||
|
- ✅ **DROPDOWN present:** `Position` (with correct items array).
|
||||||
|
- ✅ **DATE present:** `StartDate`, parsed as required.
|
||||||
|
- All base field types required for migration are present and parse as expected.
|
||||||
|
|
||||||
|
## Issues/Edge Cases
|
||||||
|
- None detected in static sample. Conditional/display logic not yet tested (all fields static).
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
Proceed to: Unit test harness for mapping function (input onboarding template, output DocuSign tabs/tokens).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Validated by Cleo, 2026-04-14*
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Mapping Test/Eval Results: Onboarding Sample
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Test that onboarding template Adobe Sign fields map to correct DocuSign tab types.
|
||||||
|
|
||||||
|
## Results (autonomous - test_mapping.py)
|
||||||
|
- All basic Adobe field types mapped to a valid DocuSign type:
|
||||||
|
- TEXT_FIELD → text
|
||||||
|
- SIGNATURE → signHere
|
||||||
|
- CHECKBOX → checkbox
|
||||||
|
- DATE → dateSigned
|
||||||
|
- DROPDOWN → list
|
||||||
|
- RADIO → radio
|
||||||
|
- All types appeared in result (see source/__test_mapping__.py output)
|
||||||
|
|
||||||
|
### Tab Map Extract (sample):
|
||||||
|
- EmployeeName: text
|
||||||
|
- StartDate: dateSigned
|
||||||
|
- Position: list (['Manager','Engineer','Tech','HR'])
|
||||||
|
- Benefits: checkbox
|
||||||
|
- CommuteOption: radio (items present)
|
||||||
|
- HRNotes: text (readOnly true)
|
||||||
|
- EmployeeSignature/HRSignature: signHere
|
||||||
|
|
||||||
|
### Issues/Edge Cases
|
||||||
|
- None: all types parsed and mapped correctly for onboarding scenario.
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
Move to: Flag incomplete/ambiguous mappings in log; then implement mapping for Adobe conditional logic as cases are encountered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Validated by Cleo, 2026-04-14*
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Round-Trip Test Flow Eval
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Exercise extract → map → ingest sequence on a real sample, verifying result at each step, surfacing issues.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
1. Extract: Sample already loaded from sample-templates/onboarding-template-formfields.json
|
||||||
|
2. Map: Already validated using test_mapping.py
|
||||||
|
3. Ingest: Already logged payload using docusign_ingest_stub.py (stub mode for now)
|
||||||
|
4. Validate: Compare source fields/types vs output tabs/fields
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- All onboarding sample fields preserved and correctly mapped in end-to-end flow.
|
||||||
|
- Output contains: text, dateSigned, list, checkbox, radio, signHere (expected values)
|
||||||
|
- No field type loss or unexpected transformation.
|
||||||
|
- (Live API ingest will add DocuSign template ID as validation in future runs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Round-trip dry-run validated by Cleo, 2026-04-14*
|
||||||
Loading…
Reference in New Issue