Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
588 lines
27 KiB
Python
588 lines
27 KiB
Python
"""
|
|
Generate the FCC CPNI Annual Certification Letter.
|
|
|
|
Produces the annual certification required by 47 CFR § 64.2009 certifying
|
|
compliance with the Customer Proprietary Network Information (CPNI) rules
|
|
(47 CFR §§ 64.2001-64.2011), including amendments from the 2023 Data Breach
|
|
Notification Order (FCC 23-111).
|
|
|
|
The letter is largely standard across carrier types. The only variation
|
|
is wholesale-only carriers, whose CPNI obligations are limited to wholesale
|
|
customer proprietary data rather than retail end-user CPNI.
|
|
|
|
Usage:
|
|
from scripts.document_gen.templates.cpni_cert_letter_generator import (
|
|
generate_cpni_cert_letter,
|
|
)
|
|
path = generate_cpni_cert_letter(
|
|
entity_name="Falcon Broadband LLC",
|
|
frn="0027160886",
|
|
filer_id_499="812345",
|
|
reporting_year=2025,
|
|
complaints_count=0,
|
|
output_path="/tmp/cpni_cert.docx",
|
|
)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
LOG = logging.getLogger("document_gen.cpni_cert")
|
|
|
|
try:
|
|
from docx import Document
|
|
from docx.shared import Pt, Inches, RGBColor
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.oxml.ns import qn
|
|
except ImportError:
|
|
LOG.warning("python-docx not installed — CPNI cert letter generation unavailable")
|
|
Document = None # type: ignore[assignment,misc]
|
|
|
|
# Navy blue used for section headings (RGB 0x1A, 0x27, 0x44)
|
|
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
|
|
|
|
# Spacing constants (in twips; 1 pt = 20 twips)
|
|
_AFTER_6PT = Pt(6) if Document else None
|
|
|
|
|
|
def generate_cpni_cert_letter(
|
|
# ── Entity identity ───────────────────────────────────────────
|
|
entity_name: str,
|
|
frn: str = "",
|
|
filer_id_499: str = "",
|
|
# ── Address ───────────────────────────────────────────────────
|
|
address_street: str = "",
|
|
address_city: str = "",
|
|
address_state: str = "",
|
|
address_zip: str = "",
|
|
# ── Contact / officer ─────────────────────────────────────────
|
|
officer_name: str = "",
|
|
officer_title: str = "Chief Executive Officer",
|
|
contact_email: str = "",
|
|
contact_phone: str = "",
|
|
# ── Reporting ─────────────────────────────────────────────────
|
|
reporting_year: int = 0,
|
|
complaints_count: int = 0,
|
|
complaints_description: str = "",
|
|
# ── Carrier flags ─────────────────────────────────────────────
|
|
is_wholesale: bool = False,
|
|
# ── Employee training ─────────────────────────────────────────
|
|
employee_training_conducted: bool = True,
|
|
# ── Disciplinary actions ──────────────────────────────────────
|
|
disciplinary_actions_taken: bool = False,
|
|
disciplinary_actions_description: str = "",
|
|
# ── Data broker actions ───────────────────────────────────────
|
|
data_broker_actions: str = "",
|
|
# ── Breaches (per FCC 23-111) ─────────────────────────────────
|
|
breaches: list[dict] | None = None,
|
|
# ── Marketing / CPNI usage ────────────────────────────────────
|
|
uses_cpni_for_marketing: bool = False,
|
|
cpni_approval_method: str = "opt_in", # "opt_in" or "opt_out"
|
|
# ── Pretexting safeguards ─────────────────────────────────────
|
|
pretexting_safeguards: str = "",
|
|
# ── Output ────────────────────────────────────────────────────
|
|
output_path: str = "/tmp/cpni_certification_letter.docx",
|
|
) -> Optional[str]:
|
|
"""
|
|
Generate a CPNI Annual Certification Letter as a DOCX file.
|
|
|
|
Compliant with 47 CFR § 64.2009, including the 2023 Data Breach
|
|
Notification Order (FCC 23-111).
|
|
|
|
Returns the output file path on success, None on failure.
|
|
"""
|
|
if Document is None:
|
|
LOG.error("python-docx not installed")
|
|
return None
|
|
|
|
if reporting_year == 0:
|
|
reporting_year = datetime.now().year - 1
|
|
|
|
if breaches is None:
|
|
breaches = []
|
|
|
|
doc = Document()
|
|
|
|
# ── Page setup ────────────────────────────────────────────────
|
|
for section in doc.sections:
|
|
section.top_margin = Inches(1)
|
|
section.bottom_margin = Inches(1)
|
|
section.left_margin = Inches(1.25)
|
|
section.right_margin = Inches(1.25)
|
|
|
|
today = datetime.now().strftime("%B %d, %Y")
|
|
signer = officer_name or "Authorized Officer"
|
|
title = officer_title or "Officer"
|
|
|
|
cpni_scope = (
|
|
"wholesale customer proprietary data"
|
|
if is_wholesale
|
|
else "customer proprietary network information (CPNI)"
|
|
)
|
|
|
|
# ── Helper functions ──────────────────────────────────────────
|
|
|
|
def _set_spacing(paragraph, after_pt=6, before_pt=0):
|
|
"""Set paragraph spacing in points."""
|
|
pf = paragraph.paragraph_format
|
|
pf.space_after = Pt(after_pt)
|
|
if before_pt:
|
|
pf.space_before = Pt(before_pt)
|
|
|
|
def _heading(text: str, level: int = 1) -> None:
|
|
"""Add a navy blue section heading."""
|
|
p = doc.add_paragraph()
|
|
run = p.add_run(text)
|
|
run.font.size = Pt(12)
|
|
run.bold = True
|
|
run.font.color.rgb = _NAVY
|
|
_set_spacing(p, after_pt=4, before_pt=8)
|
|
|
|
def _body(text: str, bold: bool = False, size: int = 10) -> None:
|
|
"""Add body-text paragraph with 6pt spacing after."""
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
run = p.add_run(text)
|
|
run.font.size = Pt(size)
|
|
run.bold = bold
|
|
_set_spacing(p, after_pt=6)
|
|
|
|
def _checkbox(label: str, checked: bool = True) -> None:
|
|
"""Add a checkbox-style line item."""
|
|
mark = "\u2611" if checked else "\u2610"
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
run = p.add_run(f" {mark} {label}")
|
|
run.font.size = Pt(10)
|
|
_set_spacing(p, after_pt=3)
|
|
|
|
def _spacer() -> None:
|
|
p = doc.add_paragraph()
|
|
_set_spacing(p, after_pt=0)
|
|
|
|
# ── Page numbers ──────────────────────────────────────────────
|
|
for section in doc.sections:
|
|
footer = section.footer
|
|
footer.is_linked_to_previous = False
|
|
fp = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
|
|
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
# Insert PAGE field
|
|
run = fp.add_run()
|
|
run.font.size = Pt(8)
|
|
run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
|
|
fld_char_begin = run._element.makeelement(qn("w:fldChar"), {qn("w:fldCharType"): "begin"})
|
|
run._element.append(fld_char_begin)
|
|
run2 = fp.add_run()
|
|
run2.font.size = Pt(8)
|
|
run2.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
|
|
instr = run2._element.makeelement(qn("w:instrText"), {})
|
|
instr.text = " PAGE "
|
|
run2._element.append(instr)
|
|
run3 = fp.add_run()
|
|
run3.font.size = Pt(8)
|
|
fld_char_end = run3._element.makeelement(qn("w:fldChar"), {qn("w:fldCharType"): "end"})
|
|
run3._element.append(fld_char_end)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# TITLE
|
|
# ══════════════════════════════════════════════════════════════
|
|
title_p = doc.add_paragraph()
|
|
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
title_run = title_p.add_run("CPNI Annual Certification Letter")
|
|
title_run.font.size = Pt(14)
|
|
title_run.bold = True
|
|
title_run.font.color.rgb = _NAVY
|
|
_set_spacing(title_p, after_pt=2)
|
|
|
|
subtitle_p = doc.add_paragraph()
|
|
subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
sub_run = subtitle_p.add_run(
|
|
f"Pursuant to 47 CFR \u00a7 64.2009 \u2014 Calendar Year {reporting_year}"
|
|
)
|
|
sub_run.font.size = Pt(10)
|
|
sub_run.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
|
|
_set_spacing(subtitle_p, after_pt=6)
|
|
|
|
# Horizontal rule
|
|
rule_p = doc.add_paragraph()
|
|
rule_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
rule_run = rule_p.add_run("\u2500" * 72)
|
|
rule_run.font.size = Pt(6)
|
|
rule_run.font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA)
|
|
_set_spacing(rule_p, after_pt=8)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 1: Provider Information
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("1. Provider Information")
|
|
|
|
info_lines = [f"Company Name: {entity_name}"]
|
|
if frn:
|
|
info_lines.append(f"FCC Registration Number (FRN): {frn}")
|
|
if filer_id_499:
|
|
info_lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
|
|
addr = ", ".join(filter(None, [address_street, address_city]))
|
|
if address_state or address_zip:
|
|
addr += f", {address_state} {address_zip}".strip()
|
|
if addr.strip(", "):
|
|
info_lines.append(f"Address: {addr.strip(', ')}")
|
|
if contact_phone:
|
|
info_lines.append(f"Telephone: {contact_phone}")
|
|
if contact_email:
|
|
info_lines.append(f"Email: {contact_email}")
|
|
info_lines.append(f"Certifying Officer: {signer}, {title}")
|
|
info_lines.append(f"Date of Filing: {today}")
|
|
info_lines.append(
|
|
f"Filing Deadline: March 1, {reporting_year + 1}"
|
|
)
|
|
|
|
_body("\n".join(info_lines))
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 2: Certification of Compliance
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("2. Certification of Compliance")
|
|
|
|
_body(
|
|
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} "
|
|
f"({'FRN: ' + frn if frn else 'FRN pending'}"
|
|
f"{', Filer ID: ' + filer_id_499 if filer_id_499 else ''}) "
|
|
f"hereby submits its annual certification of compliance with the "
|
|
f"Commission's Customer Proprietary Network Information (CPNI) rules "
|
|
f"for calendar year {reporting_year}."
|
|
)
|
|
|
|
_body(
|
|
f"I, {signer}, {title} of {entity_name}, have personal knowledge "
|
|
f"of, have reviewed, and am familiar with {entity_name}'s CPNI "
|
|
f"compliance procedures and certify that the company has established "
|
|
f"operating procedures that ensure compliance with the Commission's "
|
|
f"CPNI rules set forth in 47 CFR \u00a7\u00a7 64.2001 through 64.2011. "
|
|
f"{entity_name} has taken appropriate actions to protect the "
|
|
f"confidentiality of {cpni_scope} and has limited access to and use "
|
|
f"of such information in accordance with the Commission's rules."
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 3: Reporting Period
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("3. Reporting Period")
|
|
_body(
|
|
f"This certification covers the period from January 1, {reporting_year} "
|
|
f"through December 31, {reporting_year}."
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 4: CPNI Safeguards
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("4. CPNI Safeguards")
|
|
|
|
_body(
|
|
f"{entity_name} has implemented the following safeguards to protect "
|
|
f"{cpni_scope}:"
|
|
)
|
|
|
|
# 4a - Customer authentication
|
|
_body("(a) Customer Authentication and Password Procedures", bold=True)
|
|
_checkbox(
|
|
f"{entity_name} requires customer authentication through a password "
|
|
f"or other secure credential before disclosing CPNI in response to "
|
|
f"customer-initiated contacts, in accordance with 47 CFR \u00a7 64.2010.",
|
|
checked=True,
|
|
)
|
|
|
|
# 4b - Employee training
|
|
_body("(b) Employee Training", bold=True)
|
|
_checkbox(
|
|
f"All employees with access to CPNI have been adequately trained on "
|
|
f"the Commission's CPNI rules, including proper handling, disclosure "
|
|
f"limitations, and breach notification procedures.",
|
|
checked=employee_training_conducted,
|
|
)
|
|
if not employee_training_conducted:
|
|
_body(
|
|
f"NOTE: {entity_name} is in the process of completing employee "
|
|
f"training and anticipates full compliance within 30 days of this "
|
|
f"filing."
|
|
)
|
|
|
|
# 4c - Supervisory review
|
|
_body("(c) Supervisory Review", bold=True)
|
|
_checkbox(
|
|
f"{entity_name} conducts regular supervisory reviews of CPNI access "
|
|
f"and usage to ensure compliance with established procedures.",
|
|
checked=True,
|
|
)
|
|
|
|
# 4d - Pretexting safeguards
|
|
_body("(d) Pretexting Safeguards", bold=True)
|
|
if pretexting_safeguards:
|
|
_checkbox(pretexting_safeguards, checked=True)
|
|
else:
|
|
_checkbox(
|
|
f"{entity_name} has implemented safeguards to protect against "
|
|
f"pretexting, including customer identity verification protocols, "
|
|
f"employee awareness training on social engineering tactics, and "
|
|
f"procedures to detect and report suspected pretexting attempts.",
|
|
checked=True,
|
|
)
|
|
|
|
# 4e - Notification of account changes
|
|
_body("(e) Notification of Account Changes", bold=True)
|
|
_checkbox(
|
|
f"{entity_name} notifies customers of account changes, including "
|
|
f"changes to passwords, address of record, or online account "
|
|
f"credentials, through a communication to the customer's address "
|
|
f"of record or established backup contact method, in accordance "
|
|
f"with 47 CFR \u00a7 64.2010.",
|
|
checked=True,
|
|
)
|
|
|
|
# 4f - Record retention
|
|
_body("(f) Record Retention", bold=True)
|
|
_checkbox(
|
|
f"{entity_name} maintains records of all CPNI access, disclosures, "
|
|
f"customer complaints, and compliance actions for a minimum period "
|
|
f"of five (5) years, as required by 47 CFR \u00a7 64.2009(e).",
|
|
checked=True,
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 5: CPNI Complaints
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("5. CPNI Complaints")
|
|
|
|
if complaints_count == 0:
|
|
_body(
|
|
f"During the reporting period, {entity_name} received no complaints "
|
|
f"regarding unauthorized release or use of CPNI."
|
|
)
|
|
else:
|
|
desc = complaints_description or (
|
|
f"Each complaint was investigated and resolved in accordance with "
|
|
f"{entity_name}'s CPNI compliance procedures."
|
|
)
|
|
_body(
|
|
f"During the reporting period, {entity_name} received "
|
|
f"{complaints_count} complaint{'s' if complaints_count != 1 else ''} "
|
|
f"regarding CPNI. {desc}"
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 6: Data Breaches
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("6. Data Breaches")
|
|
|
|
if not breaches:
|
|
_body(
|
|
f"During the reporting period, {entity_name} experienced no data "
|
|
f"breaches involving CPNI. No breach notifications were required "
|
|
f"to be filed with the Commission, law enforcement, or affected "
|
|
f"customers under 47 CFR \u00a7 64.2011."
|
|
)
|
|
else:
|
|
total_breaches = len(breaches)
|
|
total_affected = sum(b.get("customers_affected", 0) for b in breaches)
|
|
_body(
|
|
f"During the reporting period, {entity_name} experienced "
|
|
f"{total_breaches} data breach{'es' if total_breaches != 1 else ''} "
|
|
f"involving CPNI, affecting a total of {total_affected:,} "
|
|
f"customer{'s' if total_affected != 1 else ''}. Details of each "
|
|
f"breach are provided below."
|
|
)
|
|
|
|
# Breach detail table
|
|
table = doc.add_table(rows=1, cols=5)
|
|
table.style = "Table Grid"
|
|
|
|
# Header row
|
|
headers = [
|
|
"Breach #", "Date", "Customers\nAffected",
|
|
"Description", "Response Actions",
|
|
]
|
|
hdr_cells = table.rows[0].cells
|
|
for i, header in enumerate(headers):
|
|
hdr_cells[i].text = ""
|
|
p = hdr_cells[i].paragraphs[0]
|
|
run = p.add_run(header)
|
|
run.bold = True
|
|
run.font.size = Pt(9)
|
|
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
# Navy background
|
|
shading = hdr_cells[i]._element.makeelement(
|
|
qn("w:shd"),
|
|
{
|
|
qn("w:val"): "clear",
|
|
qn("w:color"): "auto",
|
|
qn("w:fill"): "1A2744",
|
|
},
|
|
)
|
|
tc_pr = hdr_cells[i]._element.get_or_add_tcPr()
|
|
tc_pr.append(shading)
|
|
|
|
# Data rows
|
|
for idx, breach in enumerate(breaches, start=1):
|
|
row_cells = table.add_row().cells
|
|
values = [
|
|
str(idx),
|
|
str(breach.get("date", "N/A")),
|
|
f"{breach.get('customers_affected', 0):,}",
|
|
str(breach.get("description", "")),
|
|
str(breach.get("response_actions", "")),
|
|
]
|
|
for i, val in enumerate(values):
|
|
row_cells[i].text = ""
|
|
p = row_cells[i].paragraphs[0]
|
|
run = p.add_run(val)
|
|
run.font.size = Pt(9)
|
|
|
|
_spacer()
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 7: Disciplinary Actions
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("7. Disciplinary Actions")
|
|
|
|
if not disciplinary_actions_taken:
|
|
_body(
|
|
f"During the reporting period, {entity_name} did not take any "
|
|
f"disciplinary action against employees for violations of the "
|
|
f"Commission's CPNI rules."
|
|
)
|
|
else:
|
|
desc = disciplinary_actions_description or (
|
|
"Disciplinary action was taken in accordance with company policy."
|
|
)
|
|
_body(
|
|
f"During the reporting period, {entity_name} took disciplinary "
|
|
f"action against one or more employees for violations of the "
|
|
f"Commission's CPNI rules. {desc}"
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 8: Data Broker Actions
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("8. Actions Taken Against Data Brokers")
|
|
|
|
if data_broker_actions:
|
|
_body(
|
|
f"During the reporting period, {entity_name} took the following "
|
|
f"actions against data brokers: {data_broker_actions}"
|
|
)
|
|
else:
|
|
_body(
|
|
f"During the reporting period, {entity_name} did not identify any "
|
|
f"data brokers engaging in unauthorized access to or sale of CPNI, "
|
|
f"and no actions against data brokers were required."
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 9: CPNI Marketing Usage
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("9. CPNI Marketing Usage")
|
|
|
|
if uses_cpni_for_marketing:
|
|
method_label = (
|
|
"opt-in" if cpni_approval_method == "opt_in" else "opt-out"
|
|
)
|
|
_body(
|
|
f"{entity_name} uses CPNI for marketing purposes. Customer "
|
|
f"approval for such use is obtained through the {method_label} "
|
|
f"method, in accordance with 47 CFR \u00a7 64.2007."
|
|
)
|
|
else:
|
|
_body(
|
|
f"{entity_name} does not use CPNI for marketing purposes beyond "
|
|
f"the scope of services to which the customer already subscribes. "
|
|
f"No customer approval mechanism is required."
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 10: Breach Notification Compliance
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("10. Breach Notification Compliance")
|
|
|
|
_body(
|
|
f"{entity_name} certifies that its breach notification procedures "
|
|
f"are compliant with 47 CFR \u00a7 64.2011, as amended by the 2023 "
|
|
f"Data Breach Notification Order (FCC 23-111). These procedures "
|
|
f"include:"
|
|
)
|
|
_checkbox(
|
|
"Notification to the FCC and, where applicable, the FBI and U.S. "
|
|
"Secret Service, as soon as practicable and in no event later than "
|
|
"30 days after reasonable determination of a breach.",
|
|
checked=True,
|
|
)
|
|
_checkbox(
|
|
"Notification to affected customers as soon as practicable and in "
|
|
"no event later than 30 days after notification to law enforcement "
|
|
"(unless a delay is requested by law enforcement).",
|
|
checked=True,
|
|
)
|
|
_checkbox(
|
|
"Breach notifications include the required content specified in "
|
|
"\u00a7 64.2011, including a description of the breach, the categories "
|
|
"of information compromised, and contact information for inquiries.",
|
|
checked=True,
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SECTION 11: Officer Certification & Signature
|
|
# ══════════════════════════════════════════════════════════════
|
|
_heading("11. Officer Certification and Signature")
|
|
|
|
_body(
|
|
f"I, {signer}, {title} of {entity_name}, certify under penalty of "
|
|
f"perjury that the foregoing is true and correct. I have personal "
|
|
f"knowledge of the facts stated herein, have reviewed {entity_name}'s "
|
|
f"CPNI compliance procedures, and am satisfied that {entity_name} has "
|
|
f"complied with the requirements of 47 CFR \u00a7\u00a7 64.2001 through "
|
|
f"64.2011 during calendar year {reporting_year}."
|
|
)
|
|
|
|
_spacer()
|
|
|
|
_body("Respectfully submitted,")
|
|
_spacer()
|
|
_spacer()
|
|
|
|
# Signature line
|
|
sig_line = doc.add_paragraph()
|
|
sig_run = sig_line.add_run("_" * 45)
|
|
sig_run.font.size = Pt(10)
|
|
_set_spacing(sig_line, after_pt=2)
|
|
|
|
sig_name_p = doc.add_paragraph()
|
|
name_run = sig_name_p.add_run(signer)
|
|
name_run.font.size = Pt(10)
|
|
name_run.bold = True
|
|
_set_spacing(sig_name_p, after_pt=2)
|
|
|
|
sig_title_p = doc.add_paragraph()
|
|
sig_title_p.add_run(f"{title}, {entity_name}").font.size = Pt(10)
|
|
_set_spacing(sig_title_p, after_pt=2)
|
|
|
|
sig_date_p = doc.add_paragraph()
|
|
sig_date_p.add_run(f"Date: {today}").font.size = Pt(10)
|
|
_set_spacing(sig_date_p, after_pt=2)
|
|
|
|
if contact_phone:
|
|
sig_phone_p = doc.add_paragraph()
|
|
sig_phone_p.add_run(f"Telephone: {contact_phone}").font.size = Pt(10)
|
|
_set_spacing(sig_phone_p, after_pt=2)
|
|
|
|
if contact_email:
|
|
sig_email_p = doc.add_paragraph()
|
|
sig_email_p.add_run(f"Email: {contact_email}").font.size = Pt(10)
|
|
_set_spacing(sig_email_p, after_pt=2)
|
|
|
|
# ── Save ──────────────────────────────────────────────────────
|
|
output = Path(output_path)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
doc.save(str(output))
|
|
LOG.info("CPNI certification letter generated: %s", output)
|
|
return str(output)
|