adeo-maturity-tool/pdf_generator.py
Phil Dore 1fd3dc00ae Initial commit — ADEO Content Maturity Assessment Tool
Multi-client maturity dashboard (Express/vanilla JS, port 3102).
ADEO client: 8 markets, 7 pillars, 59 questions.
Includes data converter, universal PDF summary generator, and
new-client wizard for adding future clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:15:56 +01:00

390 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Maturity Tool — PDF Generator
Usage: python3 pdf_generator.py <client_id> <entity_id>
e.g. python3 pdf_generator.py adeo BU_LM_FRANCE
Outputs: clients/<client_id>/exports/<entity_id>_Maturity_Report.pdf
"""
import json
import sys
from datetime import date
from pathlib import Path
from collections import defaultdict
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, PageBreak, KeepTogether
)
# ── Paths ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).parent
# ── Score level colours ────────────────────────────────────────────────────────
LEVEL_COLORS = {
1: colors.HexColor("#C62828"),
2: colors.HexColor("#E65100"),
3: colors.HexColor("#2E7D32"),
4: colors.HexColor("#1B5E20"),
}
def level_color(level):
return LEVEL_COLORS.get(level, colors.HexColor("#666666"))
# ── Page geometry ──────────────────────────────────────────────────────────────
PAGE_W, PAGE_H = A4
ML = 22*mm; MR = 22*mm; MT = 26*mm; MB = 26*mm
BODY_W = PAGE_W - ML - MR
# ── Build styles from config ───────────────────────────────────────────────────
def make_styles(cfg):
C_NAVY = colors.HexColor("#1A2B3C")
accent = cfg.get("accent_color", "#78BE20")
C_ACCENT = colors.HexColor(accent)
C_BODY = colors.HexColor("#222222")
C_MID = colors.HexColor("#666666")
C_WHITE = colors.white
def S(name, **kw):
d = dict(fontName="Helvetica", fontSize=9, textColor=C_BODY, leading=14, spaceAfter=4)
d.update(kw)
return ParagraphStyle(name, **d)
ST = {
"title": S("title", fontName="Helvetica-Bold", fontSize=22, leading=28, spaceAfter=6, textColor=C_NAVY),
"sub": S("sub", fontSize=11, textColor=C_MID, leading=16, spaceAfter=2),
"h1": S("h1", fontName="Helvetica-Bold", fontSize=13, leading=17, spaceBefore=14, spaceAfter=6, textColor=C_NAVY),
"h2": S("h2", fontName="Helvetica-Bold", fontSize=10.5, leading=15, spaceBefore=10, spaceAfter=4),
"body": S("body", alignment=TA_JUSTIFY, leading=14),
"body_sm": S("body_sm",fontSize=8.5, leading=13),
"bullet": S("bullet", leftIndent=12, spaceAfter=3, leading=13),
"footer": S("footer", fontSize=7, textColor=C_MID, alignment=TA_CENTER, leading=10),
"center": S("center", alignment=TA_CENTER),
}
return ST, C_NAVY, C_ACCENT, C_BODY, C_MID, C_WHITE
def esc(t):
return str(t or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ── Helpers ────────────────────────────────────────────────────────────────────
def rule(story, sb=6, sa=6, color=None):
story.append(HRFlowable(
width="100%", thickness=0.5,
color=color or colors.HexColor("#DDDDDD"),
spaceBefore=sb, spaceAfter=sa
))
def dark_table(data, col_widths, C_NAVY, C_WHITE, C_BODY,
C_LGREY=colors.HexColor("#F7F7F7"),
C_MGREY=colors.HexColor("#CCCCCC")):
ts = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), C_NAVY),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8.5),
("ROWBACKGROUND", (0, 1), (-1, -1), [C_WHITE, C_LGREY]),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 8.5),
("TEXTCOLOR", (0, 1), (-1, -1), C_BODY),
("GRID", (0, 0), (-1, -1), 0.4, C_MGREY),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
])
return Table(data, colWidths=col_widths, style=ts, repeatRows=1)
# ── Page callbacks ─────────────────────────────────────────────────────────────
def make_callbacks(entity, cfg, report_date):
title_str = f"{entity['label']} · {cfg['description']} · Confidential"
C_MID = colors.HexColor("#666666")
C_RULE = colors.HexColor("#DDDDDD")
def first_page(c, doc):
_footer(c, doc, title_str, C_MID, C_RULE)
def later_pages(c, doc):
c.saveState()
c.setFont("Helvetica", 7.5)
c.setFillColor(C_MID)
c.drawString(ML, PAGE_H - 14*mm, title_str)
c.drawRightString(PAGE_W - MR, PAGE_H - 14*mm, f"Page {doc.page}")
c.setStrokeColor(C_RULE); c.setLineWidth(0.4)
c.line(ML, PAGE_H - 16*mm, PAGE_W - MR, PAGE_H - 16*mm)
c.restoreState()
_footer(c, doc, title_str, C_MID, C_RULE)
return first_page, later_pages
def _footer(c, doc, title_str, C_MID, C_RULE):
c.saveState()
c.setStrokeColor(C_RULE); c.setLineWidth(0.4)
c.line(ML, 14*mm, PAGE_W - MR, 14*mm)
c.setFont("Helvetica", 7); c.setFillColor(C_MID)
c.drawCentredString(PAGE_W / 2, 9*mm, title_str)
c.restoreState()
# ── Level label from scoring config ───────────────────────────────────────────
def get_level_label(level, scoring):
return scoring["labels"].get(str(level), str(level))
# ── Main generator ─────────────────────────────────────────────────────────────
def generate(client_id, entity_id):
cfg_path = SCRIPT_DIR / "clients" / client_id / "config.json"
data_path = SCRIPT_DIR / "clients" / client_id / "data.json"
out_dir = SCRIPT_DIR / "clients" / client_id / "exports"
out_dir.mkdir(parents=True, exist_ok=True)
with open(cfg_path, encoding="utf-8") as f: cfg = json.load(f)
with open(data_path, encoding="utf-8") as f: data = json.load(f)
entity = next((e for e in data["entities"] if e["id"] == entity_id), None)
if not entity:
print(f"ERROR: entity '{entity_id}' not found in {data_path}")
sys.exit(1)
scoring = cfg["scoring"]
pillar_order = cfg["pillars"]
report_date = date.today().strftime("%B %Y")
out_pdf = out_dir / f"{entity_id}_Maturity_Report.pdf"
ST, C_NAVY, C_ACCENT, C_BODY, C_MID, C_WHITE = make_styles(cfg)
C_LGREY = colors.HexColor("#F7F7F7")
C_MGREY = colors.HexColor("#CCCCCC")
C_RULE = colors.HexColor("#DDDDDD")
first_page_cb, later_pages_cb = make_callbacks(entity, cfg, report_date)
doc = SimpleDocTemplate(
str(out_pdf), pagesize=A4,
topMargin=MT, bottomMargin=MB, leftMargin=ML, rightMargin=MR
)
story = []
# ── Cover ──────────────────────────────────────────────────────────────────
story.append(Spacer(1, 10*mm))
story.append(Paragraph(esc(entity["label"]), ST["title"]))
story.append(Paragraph(esc(cfg["description"]), ST["sub"]))
rule(story, 4, 10, C_RULE)
# Overall score badge table
ov_level = entity.get("overall_level", 1)
ov_label = entity.get("overall_label", "")
ov_color = level_color(ov_level)
score_badge = [[
Paragraph(f"<b>Overall Score</b>", ParagraphStyle("ol", fontName="Helvetica-Bold", fontSize=9, textColor=C_MID, leading=13)),
Paragraph(
f"<b>{entity['overall_score']:.2f} / {scoring['max']}.00 — {esc(ov_label)}</b>",
ParagraphStyle("os", fontName="Helvetica-Bold", fontSize=13, textColor=C_WHITE, leading=17, alignment=TA_CENTER)
),
Paragraph(f"<b>Report Date</b><br/>{esc(report_date)}", ParagraphStyle("rd", fontName="Helvetica", fontSize=9, textColor=C_MID, leading=13, alignment=TA_CENTER)),
]]
score_ts = TableStyle([
("BACKGROUND", (1, 0), (1, 0), ov_color),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING",(0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("ALIGN", (1, 0), (1, 0), "CENTER"),
("ALIGN", (2, 0), (2, 0), "CENTER"),
])
story.append(Table(score_badge, colWidths=[50*mm, 80*mm, 36*mm], style=score_ts))
story.append(Spacer(1, 10*mm))
# ── Section 1: Score overview ──────────────────────────────────────────────
rule(story, 4, 0, C_RULE)
story.append(Paragraph("1. Maturity Score Overview", ST["h1"]))
pillar_summary = [["Pillar", "Questions", "Avg Score", "Level", "Min", "Max"]]
by_pillar = {p["name"]: p for p in entity["pillars"]}
all_scores = []
for pname in pillar_order:
p = by_pillar.get(pname)
if not p:
continue
qs = p["questions"]
scores = [q["score"] for q in qs]
all_scores.extend(scores)
pillar_summary.append([
pname,
str(len(qs)),
f"{p['avg']:.2f}",
get_level_label(p["level"], scoring),
str(min(scores)),
str(max(scores)),
])
pillar_summary.append(["OVERALL", str(len(all_scores)), f"{entity['overall_score']:.2f}", ov_label, str(min(all_scores)), str(max(all_scores))])
col_w = [70*mm, 22*mm, 24*mm, 28*mm, 14*mm, 14*mm]
ts_p = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), C_NAVY),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8.5),
("ROWBACKGROUND", (0, 1), (-1, -2), [colors.white, C_LGREY]),
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -2), 8.5),
("BACKGROUND", (0, -1), (-1, -1), C_NAVY),
("TEXTCOLOR", (0, -1), (-1, -1), C_WHITE),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, -1), (-1, -1), 8.5),
("GRID", (0, 0), (-1, -1), 0.4, C_MGREY),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
])
# Colour each pillar avg cell by level
for i, pname in enumerate(pillar_order, 1):
p = by_pillar.get(pname)
if p:
ts_p.add("BACKGROUND", (2, i), (2, i), level_color(p["level"]))
ts_p.add("TEXTCOLOR", (2, i), (2, i), C_WHITE)
ts_p.add("FONTNAME", (2, i), (2, i), "Helvetica-Bold")
# Overall avg
ts_p.add("BACKGROUND", (2, -1), (2, -1), ov_color)
ts_p.add("TEXTCOLOR", (2, -1), (2, -1), C_WHITE)
story.append(Table(pillar_summary, colWidths=col_w, style=ts_p, repeatRows=1))
story.append(Spacer(1, 8*mm))
# ── Sections 2+: Per-pillar question tables ────────────────────────────────
for sec_i, pname in enumerate(pillar_order, 2):
p = by_pillar.get(pname)
if not p:
continue
story.append(PageBreak())
# Pillar header with avg badge
plvl = p.get("level", 1)
p_color = level_color(plvl)
plabel = get_level_label(plvl, scoring)
rule(story, 4, 0, C_RULE)
ph = [[
Paragraph(f"<b>{sec_i}. {esc(pname)}</b>",
ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13, textColor=C_BODY, leading=18)),
Paragraph(f"<b>Avg: {p['avg']:.2f}{esc(plabel)}</b>",
ParagraphStyle("pb", fontName="Helvetica-Bold", fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER, leading=14)),
]]
ph_ts = TableStyle([
("BACKGROUND", (1, 0), (1, 0), p_color),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (0, 0), 0),
("LEFTPADDING", (1, 0), (1, 0), 6),
("RIGHTPADDING", (1, 0), (1, 0), 6),
])
story.append(Table(ph, colWidths=[BODY_W - 44*mm, 44*mm], style=ph_ts))
story.append(Spacer(1, 6*mm))
# Question score table
q_data = [["Q#", "Question Topic", "Score", "Level"]]
for q in p["questions"]:
q_data.append([
f"Q{q['q_num']}",
q["topic"][:70] + ("" if len(q["topic"]) > 70 else ""),
str(q["score"]),
q.get("label", ""),
])
q_data.append(["", f"PILLAR AVERAGE — {esc(pname)}", f"{p['avg']:.2f}", ""])
q_col = [14*mm, 108*mm, 16*mm, 28*mm]
q_ts = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), C_NAVY),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8.5),
("ROWBACKGROUND", (0, 1), (-1, -2), [colors.white, C_LGREY]),
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -2), 8.5),
("BACKGROUND", (0, -1), (-1, -1), C_NAVY),
("TEXTCOLOR", (0, -1), (-1, -1), C_WHITE),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, -1), (-1, -1), 8.5),
("GRID", (0, 0), (-1, -1), 0.4, C_MGREY),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("ALIGN", (2, 0), (2, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
])
for i, q in enumerate(p["questions"], 1):
qlvl = q.get("level", 1)
q_ts.add("BACKGROUND", (2, i), (2, i), level_color(qlvl))
q_ts.add("TEXTCOLOR", (2, i), (2, i), C_WHITE)
q_ts.add("FONTNAME", (2, i), (2, i), "Helvetica-Bold")
q_ts.add("BACKGROUND", (2, -1), (2, -1), p_color)
q_ts.add("TEXTCOLOR", (2, -1), (2, -1), C_WHITE)
story.append(Table(q_data, colWidths=q_col, style=q_ts, repeatRows=1))
story.append(Spacer(1, 8*mm))
# Rationale excerpts
story.append(Paragraph("<b>Question Rationale</b>", ST["h2"]))
for q in p["questions"]:
if not q.get("rationale"):
continue
qlvl = q.get("level", 1)
qlabel = q.get("label", "")
q_color = level_color(qlvl)
# Score chip + question number
chip = [[
Paragraph(f"<b>Q{q['q_num']} {q['score']}{esc(qlabel)}</b>",
ParagraphStyle("qchip", fontName="Helvetica-Bold", fontSize=8.5,
textColor=C_WHITE, leading=12)),
Paragraph(esc(q["topic"]),
ParagraphStyle("qtop", fontName="Helvetica-Bold", fontSize=8.5,
textColor=C_BODY, leading=12)),
]]
chip_ts = TableStyle([
("BACKGROUND", (0, 0), (0, 0), q_color),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (0, 0), 6),
("RIGHTPADDING", (0, 0), (0, 0), 6),
("LEFTPADDING", (1, 0), (1, 0), 8),
])
rationale_text = q["rationale"][:500] + ("" if len(q["rationale"]) > 500 else "")
gaps_text = q.get("gaps", "")
block = [
Table(chip, colWidths=[36*mm, BODY_W - 36*mm], style=chip_ts),
Spacer(1, 3),
Paragraph(esc(rationale_text), ST["body_sm"]),
]
if gaps_text and gaps_text.strip():
block.append(Paragraph(f"<b>Gaps:</b> {esc(gaps_text[:300])}{'' if len(gaps_text) > 300 else ''}", ST["body_sm"]))
block.append(Spacer(1, 6))
story.append(KeepTogether(block))
# ── End ────────────────────────────────────────────────────────────────────
rule(story, 16, 4, C_RULE)
story.append(Paragraph("<b>End of Report</b>", ST["body"]))
doc.build(story, onFirstPage=first_page_cb, onLaterPages=later_pages_cb)
print(f"{out_pdf} ({out_pdf.stat().st_size / 1024:.0f} KB)")
return str(out_pdf)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python3 pdf_generator.py <client_id> <entity_id>")
print(" e.g. python3 pdf_generator.py adeo BU_LM_FRANCE")
sys.exit(1)
generate(sys.argv[1], sys.argv[2])