feat: end-to-end migration runner and test template utilities
migrate_template.py — generic end-to-end CLI replacing the earlier migrate_paul_template.py: --list list available Adobe Sign templates --template "Name" download → convert → upload a named template --template "Name" --skip-upload convert only, write JSON to migration-output/ Picks most recently modified when multiple templates share a name. create_adobe_template.py — utility for creating a test template in Adobe Sign that exercises all 15+ field types. Uses the David Tag Demo Form PDF as the base document and positions extra fields (Number, Email, Company, Title) in the gaps of the original layout. generate_pdfs.py — generates realistic sample PDFs with labelled form areas matching the *-formfields.json fixtures in sample-templates/, for use in offline testing without a live Adobe Sign account. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93b6ad248a
commit
9c0910f30f
|
|
@ -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,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()
|
||||||
Loading…
Reference in New Issue