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>
390 lines
18 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
# ── 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])
|