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