- oci_pdf_gen.py: add_environment_catalogue now auto-detects new schema
(name/sizing/cost_pct) vs legacy (environment/tier/databases/ocpus),
same fix as oci_deck_gen.py. Also normalizes cost_notes string to list.
- business-case-spec.yaml: created PharmaCorp spec (was using MELI).
Fixed schema: risks as {migration_risks, do_nothing_risks}, roadmap
as {phases, total_duration}, recommendation as {summary, next_steps}.
- diagram-spec.yaml: rewrote to match generator format
(tenancy.regions[].vcns[].subnets[].services[] with explicit IDs).
Now generates 231 cells, 11 containers, 15 services, 10 connections.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1203 lines
40 KiB
Python
1203 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OCI Deal Accelerator — Customer-Facing PDF Generator
|
|
|
|
Produces a branded PDF document using Oracle Redwood design system colors
|
|
and typography. This is the CUSTOMER-FACING output — all internal KB
|
|
references, field finding IDs, gotcha codes, and internal notes are
|
|
stripped automatically.
|
|
|
|
Usage:
|
|
python oci_pdf_gen.py --spec proposal-data.yaml --output proposal.pdf
|
|
python oci_pdf_gen.py --spec proposal-data.yaml --output proposal.pdf --diagram arch.png
|
|
|
|
Or import and use programmatically:
|
|
from oci_pdf_gen import OCIPDFGenerator
|
|
gen = OCIPDFGenerator.from_spec(spec)
|
|
gen.save("proposal.pdf")
|
|
"""
|
|
|
|
import re
|
|
import yaml
|
|
import argparse
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.units import inch, mm
|
|
from reportlab.lib.colors import HexColor, white, black
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
|
PageBreak, Image, KeepTogether, HRFlowable,
|
|
)
|
|
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate, Frame
|
|
from reportlab.lib.pagesizes import letter
|
|
|
|
|
|
# ============================================================
|
|
# Oracle Redwood Design System Colors
|
|
# ============================================================
|
|
|
|
class Colors:
|
|
DARK_BG = HexColor("#312D2A")
|
|
PRIMARY_TEXT = HexColor("#312D2A")
|
|
SECONDARY_TEXT = HexColor("#6F6964")
|
|
WARM_BG = HexColor("#FCFBFA")
|
|
ORACLE_RED = HexColor("#C74634")
|
|
GOLD = HexColor("#FACD62")
|
|
TEAL = HexColor("#2C5967")
|
|
SAGE = HexColor("#759C6C")
|
|
FOREST = HexColor("#2B6242")
|
|
MUTED_TEAL = HexColor("#94AFAF")
|
|
BURNT_ORANGE = HexColor("#AE562C")
|
|
COPPER = HexColor("#AA643B")
|
|
PURPLE = HexColor("#804998")
|
|
SUCCESS = HexColor("#2B6242")
|
|
WARNING = HexColor("#AE562C")
|
|
ERROR = HexColor("#C74634")
|
|
TABLE_ALT_ROW = HexColor("#F5F4F2")
|
|
TABLE_HEADER_BG = HexColor("#2C5967")
|
|
WHITE = HexColor("#FFFFFF")
|
|
LIGHT_GRAY = HexColor("#E8E6E3")
|
|
|
|
|
|
# ============================================================
|
|
# Internal reference sanitizer
|
|
# ============================================================
|
|
|
|
# Patterns that identify internal-only content
|
|
_INTERNAL_PATTERNS = [
|
|
r'FK-\d{3}', # Field knowledge IDs
|
|
r'FF-\d{6}-\d{3}', # Field finding IDs
|
|
r'\bkb/\S+', # KB file paths
|
|
r'\bgotcha[s]?\b', # gotcha references
|
|
r'field.?finding[s]?', # field finding mentions
|
|
r'confidence:\s*(validated|observed|reported|inferred)',
|
|
r'contributor:\s*\{[^}]+\}', # contributor blocks
|
|
r'severity:\s*(HIGH|MEDIUM|LOW)\s*$', # raw severity tags
|
|
r'SR#\s*\d+', # Oracle SR numbers
|
|
]
|
|
_INTERNAL_RE = re.compile('|'.join(_INTERNAL_PATTERNS), re.IGNORECASE)
|
|
|
|
|
|
def sanitize_text(text: str) -> str:
|
|
"""Remove internal KB references from customer-facing text."""
|
|
if not text:
|
|
return text
|
|
# Remove lines that are purely internal references
|
|
lines = text.split('\n')
|
|
cleaned = []
|
|
for line in lines:
|
|
# Skip lines that are just internal IDs
|
|
stripped = line.strip()
|
|
if stripped and re.match(r'^(FK-\d{3}|FF-\d{6}-\d{3})\b', stripped):
|
|
continue
|
|
# Remove inline internal refs
|
|
line = re.sub(r'\(FK-\d{3}\)', '', line)
|
|
line = re.sub(r'\(FF-\d{6}-\d{3}\)', '', line)
|
|
line = re.sub(r'\bkb/\S+', '', line)
|
|
line = re.sub(r'\s{2,}', ' ', line).strip()
|
|
cleaned.append(line)
|
|
return '\n'.join(cleaned)
|
|
|
|
|
|
def sanitize_list(items: list) -> list:
|
|
"""Sanitize a list of strings."""
|
|
return [sanitize_text(str(item)) for item in items if item]
|
|
|
|
|
|
# ============================================================
|
|
# Styles
|
|
# ============================================================
|
|
|
|
def build_styles():
|
|
"""Build Oracle Redwood paragraph styles."""
|
|
styles = getSampleStyleSheet()
|
|
|
|
styles.add(ParagraphStyle(
|
|
'CoverTitle',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=28,
|
|
leading=34,
|
|
textColor=white,
|
|
alignment=TA_LEFT,
|
|
spaceAfter=6,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'CoverSubtitle',
|
|
fontName='Helvetica',
|
|
fontSize=18,
|
|
leading=22,
|
|
textColor=HexColor("#9E9892"),
|
|
alignment=TA_LEFT,
|
|
spaceAfter=4,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'CoverInfo',
|
|
fontName='Helvetica',
|
|
fontSize=11,
|
|
leading=14,
|
|
textColor=HexColor("#9E9892"),
|
|
alignment=TA_LEFT,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'SectionTitle',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=20,
|
|
leading=26,
|
|
textColor=Colors.PRIMARY_TEXT,
|
|
spaceBefore=18,
|
|
spaceAfter=8,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'SubsectionTitle',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=14,
|
|
leading=18,
|
|
textColor=Colors.TEAL,
|
|
spaceBefore=12,
|
|
spaceAfter=6,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'BodyText_Redwood',
|
|
fontName='Helvetica',
|
|
fontSize=10,
|
|
leading=14,
|
|
textColor=Colors.PRIMARY_TEXT,
|
|
spaceAfter=6,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'BulletItem',
|
|
fontName='Helvetica',
|
|
fontSize=10,
|
|
leading=14,
|
|
textColor=Colors.PRIMARY_TEXT,
|
|
leftIndent=18,
|
|
bulletIndent=6,
|
|
spaceAfter=3,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'Emphasis',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=11,
|
|
leading=15,
|
|
textColor=Colors.TEAL,
|
|
spaceAfter=6,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'FooterStyle',
|
|
fontName='Helvetica',
|
|
fontSize=7,
|
|
leading=9,
|
|
textColor=Colors.SECONDARY_TEXT,
|
|
alignment=TA_CENTER,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'Disclaimer',
|
|
fontName='Helvetica-Oblique',
|
|
fontSize=8,
|
|
leading=10,
|
|
textColor=Colors.SECONDARY_TEXT,
|
|
spaceAfter=4,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'ImpactStatement',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=16,
|
|
leading=22,
|
|
textColor=Colors.TEAL,
|
|
alignment=TA_LEFT,
|
|
spaceBefore=12,
|
|
spaceAfter=12,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'MetricBig',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=36,
|
|
leading=42,
|
|
textColor=Colors.ORACLE_RED,
|
|
alignment=TA_CENTER,
|
|
spaceBefore=18,
|
|
spaceAfter=6,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'TableHeader',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=9,
|
|
leading=12,
|
|
textColor=white,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'TableCell',
|
|
fontName='Helvetica',
|
|
fontSize=9,
|
|
leading=12,
|
|
textColor=Colors.PRIMARY_TEXT,
|
|
))
|
|
styles.add(ParagraphStyle(
|
|
'TableCellBold',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=9,
|
|
leading=12,
|
|
textColor=Colors.PRIMARY_TEXT,
|
|
))
|
|
return styles
|
|
|
|
|
|
# ============================================================
|
|
# PDF Generator
|
|
# ============================================================
|
|
|
|
class OCIPDFGenerator:
|
|
"""Generate Oracle Redwood-branded customer-facing PDF documents."""
|
|
|
|
PAGE_WIDTH, PAGE_HEIGHT = letter # 8.5 x 11 inches
|
|
MARGIN = 0.75 * inch
|
|
|
|
def __init__(self, customer: str = "", project: str = "",
|
|
architect: str = "", firm: str = ""):
|
|
self.customer = customer
|
|
self.project = project
|
|
self.architect = architect
|
|
self.firm = firm
|
|
self.styles = build_styles()
|
|
self.story = [] # flowable elements
|
|
self._page_num = 0
|
|
self._total_pages = 0
|
|
|
|
# ---- Page templates ----
|
|
|
|
def _header_footer(self, canvas, doc):
|
|
"""Draw header and footer on each page."""
|
|
canvas.saveState()
|
|
page_num = canvas.getPageNumber()
|
|
|
|
if page_num == 1:
|
|
# Cover page — no header/footer
|
|
canvas.restoreState()
|
|
return
|
|
|
|
# Header: thin Oracle Red line
|
|
canvas.setStrokeColor(Colors.ORACLE_RED)
|
|
canvas.setLineWidth(2)
|
|
canvas.line(
|
|
self.MARGIN, self.PAGE_HEIGHT - 0.5 * inch,
|
|
self.PAGE_WIDTH - self.MARGIN, self.PAGE_HEIGHT - 0.5 * inch,
|
|
)
|
|
|
|
# Header: customer name (left)
|
|
canvas.setFont("Helvetica", 8)
|
|
canvas.setFillColor(Colors.SECONDARY_TEXT)
|
|
canvas.drawString(
|
|
self.MARGIN,
|
|
self.PAGE_HEIGHT - 0.45 * inch,
|
|
self.customer,
|
|
)
|
|
|
|
# Header: "Confidential" (right)
|
|
canvas.drawRightString(
|
|
self.PAGE_WIDTH - self.MARGIN,
|
|
self.PAGE_HEIGHT - 0.45 * inch,
|
|
"Confidential",
|
|
)
|
|
|
|
# Footer: thin gray line
|
|
canvas.setStrokeColor(Colors.LIGHT_GRAY)
|
|
canvas.setLineWidth(0.5)
|
|
canvas.line(
|
|
self.MARGIN, 0.55 * inch,
|
|
self.PAGE_WIDTH - self.MARGIN, 0.55 * inch,
|
|
)
|
|
|
|
# Footer: page number (center)
|
|
canvas.setFont("Helvetica", 8)
|
|
canvas.setFillColor(Colors.SECONDARY_TEXT)
|
|
canvas.drawCentredString(
|
|
self.PAGE_WIDTH / 2,
|
|
0.4 * inch,
|
|
f"Page {page_num}",
|
|
)
|
|
|
|
# Footer: date (right)
|
|
canvas.drawRightString(
|
|
self.PAGE_WIDTH - self.MARGIN,
|
|
0.4 * inch,
|
|
datetime.now().strftime("%B %Y"),
|
|
)
|
|
|
|
canvas.restoreState()
|
|
|
|
def _thank_you_page(self, canvas, doc):
|
|
"""Draw the Thank You page background (dark, matches cover)."""
|
|
canvas.saveState()
|
|
canvas.setFillColor(Colors.DARK_BG)
|
|
canvas.rect(0, 0, self.PAGE_WIDTH, self.PAGE_HEIGHT, fill=1, stroke=0)
|
|
# Oracle Red accent bar matching cover
|
|
canvas.setFillColor(Colors.ORACLE_RED)
|
|
canvas.rect(
|
|
0.6 * inch, 3.2 * inch,
|
|
4, 2.2 * inch,
|
|
fill=1, stroke=0,
|
|
)
|
|
canvas.restoreState()
|
|
|
|
def _cover_page(self, canvas, doc):
|
|
"""Draw the cover page background."""
|
|
canvas.saveState()
|
|
|
|
# Full dark background
|
|
canvas.setFillColor(Colors.DARK_BG)
|
|
canvas.rect(0, 0, self.PAGE_WIDTH, self.PAGE_HEIGHT, fill=1, stroke=0)
|
|
|
|
# Oracle Red vertical accent bar
|
|
canvas.setFillColor(Colors.ORACLE_RED)
|
|
canvas.rect(
|
|
0.6 * inch, 3.2 * inch,
|
|
4, 2.2 * inch,
|
|
fill=1, stroke=0,
|
|
)
|
|
|
|
# "Prepared with OCI Deal Accelerator" at bottom
|
|
canvas.setFont("Helvetica-Oblique", 8)
|
|
canvas.setFillColor(HexColor("#6F6964"))
|
|
canvas.drawString(
|
|
0.85 * inch, 1.2 * inch,
|
|
"Prepared with OCI Deal Accelerator",
|
|
)
|
|
|
|
# Oracle safe harbor footer
|
|
canvas.setFont("Helvetica", 6)
|
|
canvas.setFillColor(HexColor("#6F6964"))
|
|
canvas.drawString(
|
|
0.85 * inch, 0.7 * inch,
|
|
"This document is provided for informational purposes only. "
|
|
"Actual results may vary.",
|
|
)
|
|
|
|
canvas.restoreState()
|
|
|
|
# ---- Section builders ----
|
|
|
|
def add_cover(self, subtitle: str = ""):
|
|
"""Add cover page elements (text positioned over the dark bg)."""
|
|
# Spacer to push content down (cover bg is drawn by page template)
|
|
self.story.append(Spacer(1, 3.0 * inch))
|
|
|
|
# Customer name
|
|
title = self.customer or "Architecture Proposal"
|
|
self.story.append(Paragraph(title, self.styles['CoverTitle']))
|
|
|
|
# Project
|
|
if self.project:
|
|
self.story.append(Paragraph(self.project, self.styles['CoverSubtitle']))
|
|
|
|
# Subtitle
|
|
date_str = datetime.now().strftime("%B %Y")
|
|
sub = subtitle or f"Architecture Proposal — {date_str}"
|
|
self.story.append(Spacer(1, 6))
|
|
self.story.append(Paragraph(sub, self.styles['CoverInfo']))
|
|
|
|
# Architect / Firm
|
|
if self.architect or self.firm:
|
|
info = self.architect
|
|
if self.firm:
|
|
info += f" | {self.firm}" if self.architect else self.firm
|
|
self.story.append(Spacer(1, 1.5 * inch))
|
|
self.story.append(Paragraph(info, self.styles['CoverInfo']))
|
|
|
|
self.story.append(PageBreak())
|
|
|
|
def _add_section_title(self, title: str):
|
|
"""Add a section title with Oracle Red underline."""
|
|
self.story.append(Paragraph(title, self.styles['SectionTitle']))
|
|
self.story.append(HRFlowable(
|
|
width="25%", thickness=2,
|
|
color=Colors.ORACLE_RED,
|
|
spaceAfter=10, spaceBefore=0,
|
|
hAlign='LEFT',
|
|
))
|
|
|
|
def _add_subsection(self, title: str):
|
|
"""Add a subsection title."""
|
|
self.story.append(Paragraph(title, self.styles['SubsectionTitle']))
|
|
|
|
def _add_body(self, text: str):
|
|
"""Add body text (sanitized)."""
|
|
text = sanitize_text(text)
|
|
if text:
|
|
self.story.append(Paragraph(text, self.styles['BodyText_Redwood']))
|
|
|
|
def _add_bullet_list(self, items: list):
|
|
"""Add a bulleted list (sanitized)."""
|
|
for item in sanitize_list(items):
|
|
if item.strip():
|
|
self.story.append(Paragraph(
|
|
f"• {item}", self.styles['BulletItem'],
|
|
))
|
|
|
|
def _make_table(self, headers: list, rows: list,
|
|
col_widths: list = None) -> Table:
|
|
"""Build a styled table."""
|
|
# Header row
|
|
header_cells = [
|
|
Paragraph(h, self.styles['TableHeader']) for h in headers
|
|
]
|
|
# Data rows
|
|
data = [header_cells]
|
|
for row in rows:
|
|
data.append([
|
|
Paragraph(sanitize_text(str(cell)), self.styles['TableCell'])
|
|
for cell in row
|
|
])
|
|
|
|
table = Table(data, colWidths=col_widths, repeatRows=1)
|
|
|
|
# Style
|
|
style_cmds = [
|
|
('BACKGROUND', (0, 0), (-1, 0), Colors.TABLE_HEADER_BG),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), white),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 9),
|
|
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('GRID', (0, 0), (-1, -1), 0.5, Colors.LIGHT_GRAY),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
|
]
|
|
# Alternating row colors
|
|
for i in range(1, len(data)):
|
|
if i % 2 == 0:
|
|
style_cmds.append(
|
|
('BACKGROUND', (0, i), (-1, i), Colors.TABLE_ALT_ROW)
|
|
)
|
|
|
|
table.setStyle(TableStyle(style_cmds))
|
|
return table
|
|
|
|
# ---- Content sections ----
|
|
|
|
def add_executive_summary(self, why: str, current_state: list,
|
|
target_state: str, timeline: str):
|
|
"""Executive Summary section."""
|
|
self._add_section_title("Executive Summary")
|
|
|
|
# Impact statement
|
|
self.story.append(Paragraph(
|
|
sanitize_text(why), self.styles['ImpactStatement'],
|
|
))
|
|
self.story.append(Spacer(1, 8))
|
|
|
|
# Current state
|
|
self._add_subsection("Current State")
|
|
self._add_bullet_list(current_state)
|
|
|
|
# Target state
|
|
self._add_subsection("Target State")
|
|
self._add_body(target_state)
|
|
|
|
# Timeline
|
|
self.story.append(Spacer(1, 6))
|
|
self.story.append(Paragraph(
|
|
f"<b>Timeline:</b> {sanitize_text(timeline)}",
|
|
self.styles['BodyText_Redwood'],
|
|
))
|
|
|
|
def add_service_tiering(self, workloads: list):
|
|
"""Service Tiering section."""
|
|
self._add_section_title("Service Tiering")
|
|
self._add_body(
|
|
"Each tier drives HA/DR topology, backup strategy, "
|
|
"isolation model, and support level."
|
|
)
|
|
|
|
headers = ["Workload", "Tier", "Uptime", "RTO", "RPO"]
|
|
rows = [
|
|
[wl.get("name", ""), wl.get("tier", ""),
|
|
wl.get("uptime", ""), wl.get("rto", ""), wl.get("rpo", "")]
|
|
for wl in workloads
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.28, avail * 0.15, avail * 0.17,
|
|
avail * 0.20, avail * 0.20]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_architecture_principles(self, principles: dict):
|
|
"""Architecture Principles section."""
|
|
self._add_section_title("Architecture Principles")
|
|
|
|
for category in ["design", "deployment", "service"]:
|
|
items = principles.get(category, [])
|
|
if not items:
|
|
continue
|
|
self._add_subsection(category.title())
|
|
for item in items:
|
|
pid = item.get("id", "")
|
|
name = item.get("name", "")
|
|
summary = item.get("summary", "")
|
|
label = f"<b>{pid} {name}</b>" if pid else f"<b>{name}</b>"
|
|
if summary:
|
|
label += f" — {summary}"
|
|
self.story.append(Paragraph(
|
|
f"• {label}", self.styles['BulletItem'],
|
|
))
|
|
|
|
def add_architecture_overview(self, description: str = "",
|
|
diagram_path: str = None):
|
|
"""Architecture Overview section."""
|
|
self._add_section_title("Architecture Overview")
|
|
|
|
if diagram_path and Path(diagram_path).is_file():
|
|
try:
|
|
img = Image(diagram_path)
|
|
avail_w = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
avail_h = 4.5 * inch
|
|
# Scale to fit
|
|
ratio = min(avail_w / img.drawWidth, avail_h / img.drawHeight)
|
|
img.drawWidth *= ratio
|
|
img.drawHeight *= ratio
|
|
self.story.append(img)
|
|
self.story.append(Spacer(1, 8))
|
|
except Exception:
|
|
self._add_body(f"[Diagram: {diagram_path}]")
|
|
|
|
if description:
|
|
self._add_body(description)
|
|
|
|
def add_decisions(self, decisions: list):
|
|
"""Architecture Decisions section."""
|
|
self._add_section_title("Architecture Decisions")
|
|
|
|
headers = ["Decision", "Rationale"]
|
|
rows = [
|
|
[d.get("decision", ""), d.get("rationale", "")]
|
|
for d in decisions
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.35, avail * 0.65]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_ha_dr(self, tiers: list, description: str = ""):
|
|
"""HA/DR section."""
|
|
self._add_section_title("High Availability & Disaster Recovery")
|
|
|
|
if description:
|
|
self._add_body(description)
|
|
self.story.append(Spacer(1, 6))
|
|
|
|
headers = ["Component", "Technology", "RTO", "RPO"]
|
|
rows = [
|
|
[t.get("tier", ""), t.get("technology", ""),
|
|
t.get("rto", ""), t.get("rpo", "")]
|
|
for t in tiers
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.22, avail * 0.38, avail * 0.20, avail * 0.20]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_security(self, controls: dict, compliance: list = None):
|
|
"""Security & Compliance section."""
|
|
self._add_section_title("Security & Compliance")
|
|
|
|
if compliance:
|
|
badges = " | ".join([f"✓ {fw}" for fw in compliance])
|
|
self.story.append(Paragraph(
|
|
f"<b>{badges}</b>", self.styles['Emphasis'],
|
|
))
|
|
self.story.append(Spacer(1, 6))
|
|
|
|
for area, items in controls.items():
|
|
area_title = area.replace("_", " ").title()
|
|
self._add_subsection(area_title)
|
|
self._add_bullet_list(items)
|
|
|
|
def add_environment_catalogue(self, environments: list,
|
|
cost_notes=None):
|
|
"""Environment Catalogue section."""
|
|
self._add_section_title("Environment Catalogue")
|
|
|
|
# Normalize cost_notes to list
|
|
if isinstance(cost_notes, str):
|
|
cost_notes = [s.strip() for s in cost_notes.split(".") if s.strip()]
|
|
|
|
# Detect schema: new (name/sizing/cost_pct) vs legacy (environment/tier/databases/ocpus)
|
|
sample = environments[0] if environments else {}
|
|
if "sizing" in sample or "cost_pct" in sample:
|
|
headers = ["Environment", "Sizing", "Isolation", "Cost %"]
|
|
rows = [
|
|
[e.get("name", e.get("environment", "")),
|
|
e.get("sizing", ""),
|
|
e.get("isolation", ""),
|
|
f"{e.get('cost_pct', '')}%" if e.get("cost_pct", "") != "" else ""]
|
|
for e in environments
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.18, avail * 0.40, avail * 0.27, avail * 0.15]
|
|
else:
|
|
headers = ["Environment", "Tier", "Databases", "OCPUs", "Isolation"]
|
|
rows = [
|
|
[e.get("environment", ""), e.get("tier", ""),
|
|
e.get("databases", ""), e.get("ocpus", ""),
|
|
e.get("isolation", "")]
|
|
for e in environments
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.16, avail * 0.10, avail * 0.20,
|
|
avail * 0.10, avail * 0.44]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
|
|
if cost_notes:
|
|
self.story.append(Spacer(1, 8))
|
|
self._add_subsection("Cost Optimization")
|
|
self._add_bullet_list(cost_notes)
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_cost_estimate(self, line_items: list, assumptions: list = None,
|
|
show_byol: bool = True):
|
|
"""Cost Estimate section."""
|
|
self._add_section_title("Cost Estimate")
|
|
|
|
if show_byol:
|
|
headers = ["Component", "Monthly (PAYG)", "Monthly (BYOL)", "Notes"]
|
|
rows = [
|
|
[item.get("component", ""),
|
|
item.get("monthly_payg", ""),
|
|
item.get("monthly_byol", ""),
|
|
item.get("notes", "")]
|
|
for item in line_items
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.30, avail * 0.18, avail * 0.18, avail * 0.34]
|
|
else:
|
|
headers = ["Component", "Monthly", "Notes"]
|
|
rows = [
|
|
[item.get("component", ""),
|
|
item.get("monthly_payg", ""),
|
|
item.get("notes", "")]
|
|
for item in line_items
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.35, avail * 0.20, avail * 0.45]
|
|
|
|
table = self._make_table(headers, rows, widths)
|
|
|
|
# Highlight total rows
|
|
extra_cmds = []
|
|
for i, item in enumerate(line_items):
|
|
if "total" in item.get("component", "").lower():
|
|
row_idx = i + 1
|
|
extra_cmds.extend([
|
|
('BACKGROUND', (0, row_idx), (-1, row_idx), Colors.TEAL),
|
|
('TEXTCOLOR', (0, row_idx), (-1, row_idx), white),
|
|
('FONTNAME', (0, row_idx), (-1, row_idx), 'Helvetica-Bold'),
|
|
])
|
|
if extra_cmds:
|
|
table.setStyle(TableStyle(extra_cmds))
|
|
|
|
self.story.append(table)
|
|
|
|
if assumptions:
|
|
self.story.append(Spacer(1, 10))
|
|
self.story.append(Paragraph(
|
|
"<b>Assumptions:</b>", self.styles['Disclaimer'],
|
|
))
|
|
for a in sanitize_list(assumptions):
|
|
self.story.append(Paragraph(
|
|
f"• {a}", self.styles['Disclaimer'],
|
|
))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_cost_comparison(self, rows: list, title: str = "Cost Comparison",
|
|
col_headers: list = None):
|
|
"""Cost Comparison section."""
|
|
self._add_section_title(title)
|
|
|
|
headers = col_headers or ["Component", "Current", "OCI", "Savings"]
|
|
data_rows = [
|
|
[r.get("item", ""), r.get("current", ""),
|
|
r.get("oci", ""), r.get("savings", "")]
|
|
for r in rows
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.35, avail * 0.20, avail * 0.20, avail * 0.25]
|
|
table = self._make_table(headers, data_rows, widths)
|
|
|
|
# Highlight total rows
|
|
extra_cmds = []
|
|
for i, r in enumerate(rows):
|
|
if "total" in r.get("item", "").lower():
|
|
row_idx = i + 1
|
|
extra_cmds.extend([
|
|
('BACKGROUND', (0, row_idx), (-1, row_idx), Colors.TEAL),
|
|
('TEXTCOLOR', (0, row_idx), (-1, row_idx), white),
|
|
('FONTNAME', (0, row_idx), (-1, row_idx), 'Helvetica-Bold'),
|
|
])
|
|
if extra_cmds:
|
|
table.setStyle(TableStyle(extra_cmds))
|
|
|
|
self.story.append(table)
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_migration(self, phases: list, tools: list = None,
|
|
downtime: str = ""):
|
|
"""Migration Approach section."""
|
|
self._add_section_title("Migration Approach")
|
|
|
|
headers = ["Phase", "Timeline", "Key Activities"]
|
|
rows = [
|
|
[p.get("name", ""), p.get("weeks", ""),
|
|
" | ".join(p.get("tasks", [])[:4])]
|
|
for p in phases
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.25, avail * 0.15, avail * 0.60]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
|
|
if tools:
|
|
self.story.append(Spacer(1, 8))
|
|
self.story.append(Paragraph(
|
|
f"<b>Migration Tools:</b> {', '.join(tools)}",
|
|
self.styles['BodyText_Redwood'],
|
|
))
|
|
if downtime:
|
|
self.story.append(Paragraph(
|
|
f"<b>Downtime Approach:</b> {sanitize_text(downtime)}",
|
|
self.styles['BodyText_Redwood'],
|
|
))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_operational_raci(self, raci_items: list,
|
|
model: str = "co_managed"):
|
|
"""Operational RACI section."""
|
|
model_label = model.replace("_", "-").title()
|
|
self._add_section_title(f"Operational Responsibilities ({model_label})")
|
|
|
|
headers = ["Activity", "Customer", "Oracle / Partner"]
|
|
rows = [
|
|
[item.get("activity", ""),
|
|
item.get("customer", ""),
|
|
item.get("oracle", "")]
|
|
for item in raci_items
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.50, avail * 0.25, avail * 0.25]
|
|
self.story.append(self._make_table(headers, rows, widths))
|
|
|
|
self.story.append(Spacer(1, 6))
|
|
self.story.append(Paragraph(
|
|
"R = Responsible | A = Accountable | C = Consulted | I = Informed",
|
|
self.styles['Disclaimer'],
|
|
))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_risks(self, risks: list):
|
|
"""Risk Register section."""
|
|
self._add_section_title("Risk Register")
|
|
|
|
headers = ["Risk", "Severity", "Mitigation"]
|
|
rows = [
|
|
[sanitize_text(r.get("risk", "")),
|
|
r.get("severity", "MEDIUM"),
|
|
sanitize_text(r.get("mitigation", ""))]
|
|
for r in risks
|
|
]
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.30, avail * 0.12, avail * 0.58]
|
|
table = self._make_table(headers, rows, widths)
|
|
|
|
# Color-code severity
|
|
severity_colors = {
|
|
"HIGH": Colors.ERROR,
|
|
"MEDIUM": Colors.WARNING,
|
|
"LOW": Colors.SUCCESS,
|
|
}
|
|
extra_cmds = []
|
|
for i, risk in enumerate(risks):
|
|
sev = risk.get("severity", "MEDIUM").upper()
|
|
color = severity_colors.get(sev, Colors.WARNING)
|
|
row_idx = i + 1
|
|
extra_cmds.extend([
|
|
('TEXTCOLOR', (1, row_idx), (1, row_idx), color),
|
|
('FONTNAME', (1, row_idx), (1, row_idx), 'Helvetica-Bold'),
|
|
('ALIGN', (1, row_idx), (1, row_idx), 'CENTER'),
|
|
])
|
|
if extra_cmds:
|
|
table.setStyle(TableStyle(extra_cmds))
|
|
|
|
self.story.append(table)
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_scorecard(self, pillars: list, recommendations: list = None):
|
|
"""Well-Architected Scorecard section."""
|
|
self._add_section_title("Well-Architected Scorecard")
|
|
|
|
status_labels = {
|
|
"PASS": "✓ Pass",
|
|
"PASS_WITH_RECOMMENDATIONS": "⚠ Pass with Recommendations",
|
|
"GAPS_IDENTIFIED": "✗ Gaps Identified",
|
|
"NOT_APPLICABLE": "— N/A",
|
|
}
|
|
|
|
headers = ["Pillar", "Score", "Status"]
|
|
rows = []
|
|
for p in pillars:
|
|
score = (f"{p.get('passed', 0)}/{p.get('total', 0)}"
|
|
if p.get('total', 0) > 0 else "—")
|
|
status = status_labels.get(p.get("status", ""), p.get("status", ""))
|
|
rows.append([p.get("name", ""), score, status])
|
|
|
|
avail = self.PAGE_WIDTH - 2 * self.MARGIN
|
|
widths = [avail * 0.40, avail * 0.15, avail * 0.45]
|
|
table = self._make_table(headers, rows, widths)
|
|
|
|
# Color-code status
|
|
status_colors = {
|
|
"PASS": Colors.SUCCESS,
|
|
"PASS_WITH_RECOMMENDATIONS": Colors.WARNING,
|
|
"GAPS_IDENTIFIED": Colors.ERROR,
|
|
"NOT_APPLICABLE": Colors.SECONDARY_TEXT,
|
|
}
|
|
extra_cmds = []
|
|
for i, p in enumerate(pillars):
|
|
color = status_colors.get(p.get("status", ""), Colors.SECONDARY_TEXT)
|
|
row_idx = i + 1
|
|
extra_cmds.extend([
|
|
('TEXTCOLOR', (2, row_idx), (2, row_idx), color),
|
|
('FONTNAME', (2, row_idx), (2, row_idx), 'Helvetica-Bold'),
|
|
])
|
|
if extra_cmds:
|
|
table.setStyle(TableStyle(extra_cmds))
|
|
|
|
self.story.append(table)
|
|
|
|
if recommendations:
|
|
self.story.append(Spacer(1, 10))
|
|
self._add_subsection("Top Recommendations")
|
|
for rec in sanitize_list(recommendations[:6]):
|
|
self.story.append(Paragraph(
|
|
f"→ {rec}", self.styles['BulletItem'],
|
|
))
|
|
|
|
self.story.append(Spacer(1, 6))
|
|
self.story.append(Paragraph(
|
|
"Validated against Oracle Well-Architected Framework — "
|
|
"docs.oracle.com/en/solutions/oci-best-practices/",
|
|
self.styles['Disclaimer'],
|
|
))
|
|
self.story.append(Spacer(1, 12))
|
|
|
|
def add_next_steps(self, steps: list, contact_info: str = ""):
|
|
"""Next Steps section."""
|
|
self._add_section_title("Next Steps")
|
|
|
|
for i, step in enumerate(sanitize_list(steps)):
|
|
self.story.append(Paragraph(
|
|
f"<b>{i + 1}.</b> {step}",
|
|
self.styles['BodyText_Redwood'],
|
|
))
|
|
self.story.append(Spacer(1, 4))
|
|
|
|
if contact_info:
|
|
self.story.append(Spacer(1, 18))
|
|
self.story.append(Paragraph(
|
|
sanitize_text(contact_info),
|
|
self.styles['Emphasis'],
|
|
))
|
|
|
|
def add_thank_you_page(self):
|
|
"""Add a dark-background closing / Thank You page."""
|
|
self.story.append(PageBreak())
|
|
# The _thank_you_page canvas callback draws the dark background
|
|
from reportlab.platypus.doctemplate import NextPageTemplate
|
|
self.story.append(NextPageTemplate('thank_you'))
|
|
self.story.append(Spacer(1, 2.2 * inch))
|
|
self.story.append(Paragraph(
|
|
"Thank you",
|
|
ParagraphStyle(
|
|
'ThankYouTitle',
|
|
fontName='Helvetica-Bold',
|
|
fontSize=40,
|
|
leading=50,
|
|
textColor=white,
|
|
alignment=TA_LEFT,
|
|
),
|
|
))
|
|
self.story.append(Spacer(1, 0.3 * inch))
|
|
if self.architect or self.firm:
|
|
parts = [p for p in [self.architect, self.firm] if p]
|
|
self.story.append(Paragraph(
|
|
" | ".join(parts),
|
|
ParagraphStyle(
|
|
'ThankYouContact',
|
|
fontName='Helvetica',
|
|
fontSize=13,
|
|
leading=18,
|
|
textColor=HexColor("#9E9892"),
|
|
alignment=TA_LEFT,
|
|
),
|
|
))
|
|
|
|
def add_safe_harbor(self):
|
|
"""Add Oracle Safe Harbor statement."""
|
|
from reportlab.platypus.doctemplate import NextPageTemplate
|
|
self.story.append(NextPageTemplate('content_pages'))
|
|
self.story.append(PageBreak())
|
|
self.story.append(Spacer(1, 2 * inch))
|
|
self.story.append(Paragraph(
|
|
"Safe Harbor Statement",
|
|
self.styles['SectionTitle'],
|
|
))
|
|
self.story.append(HRFlowable(
|
|
width="25%", thickness=2,
|
|
color=Colors.ORACLE_RED,
|
|
spaceAfter=14, spaceBefore=0,
|
|
hAlign='LEFT',
|
|
))
|
|
self.story.append(Paragraph(
|
|
"The preceding is intended to outline our general product direction. "
|
|
"It is intended for information purposes only, and may not be "
|
|
"incorporated into any contract. It is not a commitment to deliver "
|
|
"any material, code, or functionality, and should not be relied upon "
|
|
"in making purchasing decisions. The development, release, timing, "
|
|
"and pricing of any features or functionality described for Oracle's "
|
|
"products may change and remains at the sole discretion of Oracle "
|
|
"Corporation.",
|
|
self.styles['BodyText_Redwood'],
|
|
))
|
|
|
|
# ---- Build from spec ----
|
|
|
|
@classmethod
|
|
def from_spec(cls, spec: dict, diagram_path: str = None) -> "OCIPDFGenerator":
|
|
"""Build a complete PDF from a YAML specification."""
|
|
meta = spec.get("metadata", {})
|
|
gen = cls(
|
|
customer=meta.get("customer", ""),
|
|
project=meta.get("project", ""),
|
|
architect=meta.get("architect", ""),
|
|
firm=meta.get("firm", ""),
|
|
)
|
|
|
|
# Cover
|
|
gen.add_cover(subtitle=meta.get("subtitle", ""))
|
|
|
|
# Executive Summary
|
|
if "summary" in spec:
|
|
s = spec["summary"]
|
|
gen.add_executive_summary(
|
|
why=s.get("why", ""),
|
|
current_state=s.get("current_state", []),
|
|
target_state=s.get("target_state", ""),
|
|
timeline=s.get("timeline", ""),
|
|
)
|
|
|
|
# Service Tiering
|
|
if "service_tiering" in spec:
|
|
gen.add_service_tiering(spec["service_tiering"])
|
|
|
|
# Architecture Principles
|
|
if "architecture_principles" in spec:
|
|
gen.add_architecture_principles(spec["architecture_principles"])
|
|
|
|
# Architecture Overview
|
|
if "architecture" in spec:
|
|
a = spec["architecture"]
|
|
gen.add_architecture_overview(
|
|
description=a.get("description", ""),
|
|
diagram_path=diagram_path or a.get("diagram_path"),
|
|
)
|
|
|
|
# Decisions
|
|
if "decisions" in spec:
|
|
gen.add_decisions(spec["decisions"])
|
|
|
|
# HA/DR
|
|
if "ha_dr" in spec:
|
|
h = spec["ha_dr"]
|
|
gen.add_ha_dr(
|
|
tiers=h.get("tiers", []),
|
|
description=h.get("description", ""),
|
|
)
|
|
|
|
# Security
|
|
if "security" in spec:
|
|
s = spec["security"]
|
|
gen.add_security(
|
|
controls=s.get("controls", {}),
|
|
compliance=s.get("compliance", []),
|
|
)
|
|
|
|
# Environment Catalogue
|
|
if "environment_catalogue" in spec:
|
|
ec = spec["environment_catalogue"]
|
|
gen.add_environment_catalogue(
|
|
environments=ec.get("environments", []),
|
|
cost_notes=ec.get("cost_notes"),
|
|
)
|
|
|
|
# Cost Estimate
|
|
if "cost" in spec:
|
|
c = spec["cost"]
|
|
gen.add_cost_estimate(
|
|
line_items=c.get("line_items", []),
|
|
assumptions=c.get("assumptions", []),
|
|
show_byol=c.get("show_byol", True),
|
|
)
|
|
|
|
# Cost Comparison
|
|
if "cost_comparison" in spec:
|
|
cc = spec["cost_comparison"]
|
|
gen.add_cost_comparison(
|
|
rows=cc.get("rows", []),
|
|
title=cc.get("title", "Cost Comparison"),
|
|
col_headers=cc.get("col_headers"),
|
|
)
|
|
|
|
# Migration
|
|
if "migration" in spec:
|
|
m = spec["migration"]
|
|
gen.add_migration(
|
|
phases=m.get("phases", []),
|
|
tools=m.get("tools", []),
|
|
downtime=m.get("downtime", ""),
|
|
)
|
|
|
|
# Operational RACI
|
|
if "operational_raci" in spec:
|
|
r = spec["operational_raci"]
|
|
gen.add_operational_raci(
|
|
raci_items=r.get("raci_items", []),
|
|
model=r.get("model", "co_managed"),
|
|
)
|
|
|
|
# Risks
|
|
if "risks" in spec:
|
|
gen.add_risks(spec["risks"])
|
|
|
|
# Scorecard
|
|
if "scorecard" in spec:
|
|
sc = spec["scorecard"]
|
|
gen.add_scorecard(
|
|
pillars=sc.get("pillars", []),
|
|
recommendations=sc.get("recommendations", []),
|
|
)
|
|
|
|
# Next Steps
|
|
if "next_steps" in spec:
|
|
ns = spec["next_steps"]
|
|
gen.add_next_steps(
|
|
steps=ns.get("steps", []),
|
|
contact_info=ns.get("contact_info", ""),
|
|
)
|
|
|
|
# Thank You + Safe Harbor
|
|
gen.add_thank_you_page()
|
|
gen.add_safe_harbor()
|
|
|
|
return gen
|
|
|
|
# ---- Save ----
|
|
|
|
def save(self, filepath: str):
|
|
"""Save the document to a PDF file."""
|
|
doc = BaseDocTemplate(
|
|
filepath,
|
|
pagesize=letter,
|
|
leftMargin=self.MARGIN,
|
|
rightMargin=self.MARGIN,
|
|
topMargin=0.7 * inch,
|
|
bottomMargin=0.7 * inch,
|
|
title=f"{self.customer} — {self.project}",
|
|
author=self.architect,
|
|
subject="OCI Architecture Proposal",
|
|
)
|
|
|
|
# Frame for content
|
|
content_frame = Frame(
|
|
self.MARGIN, 0.7 * inch,
|
|
self.PAGE_WIDTH - 2 * self.MARGIN,
|
|
self.PAGE_HEIGHT - 1.4 * inch,
|
|
id='content',
|
|
)
|
|
|
|
# Cover page template (dark bg)
|
|
cover_template = PageTemplate(
|
|
id='cover',
|
|
frames=[content_frame],
|
|
onPage=self._cover_page,
|
|
)
|
|
|
|
# Content page template (header + footer)
|
|
content_template = PageTemplate(
|
|
id='content_pages',
|
|
frames=[content_frame],
|
|
onPage=self._header_footer,
|
|
)
|
|
|
|
# Thank You page template (dark bg, no header/footer)
|
|
thank_you_template = PageTemplate(
|
|
id='thank_you',
|
|
frames=[content_frame],
|
|
onPage=self._thank_you_page,
|
|
)
|
|
|
|
doc.addPageTemplates([cover_template, content_template, thank_you_template])
|
|
|
|
# After cover page, switch to content template
|
|
# Insert a NextPageTemplate after the PageBreak in story
|
|
from reportlab.platypus.doctemplate import NextPageTemplate
|
|
# Find first PageBreak and insert template switch before it
|
|
for i, elem in enumerate(self.story):
|
|
if isinstance(elem, PageBreak):
|
|
self.story.insert(i, NextPageTemplate('content_pages'))
|
|
break
|
|
|
|
doc.build(self.story)
|
|
|
|
|
|
# ============================================================
|
|
# CLI
|
|
# ============================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate customer-facing OCI architecture proposal PDF"
|
|
)
|
|
parser.add_argument(
|
|
"--spec", required=True,
|
|
help="Path to YAML spec file with proposal data",
|
|
)
|
|
parser.add_argument(
|
|
"--output", default="architecture-proposal.pdf",
|
|
help="Output .pdf file path",
|
|
)
|
|
parser.add_argument(
|
|
"--diagram",
|
|
help="Path to architecture diagram image (.png/.jpg) to embed",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
with open(args.spec, 'r') as f:
|
|
spec = yaml.safe_load(f)
|
|
|
|
gen = OCIPDFGenerator.from_spec(spec, diagram_path=args.diagram)
|
|
gen.save(args.output)
|
|
|
|
customer = spec.get("metadata", {}).get("customer", "")
|
|
print(f"Generated: {args.output}")
|
|
print(f" Style: Oracle Redwood (customer-facing)")
|
|
print(f" Internal references: stripped")
|
|
if customer:
|
|
print(f" Customer: {customer}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|