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>
778 lines
33 KiB
Python
778 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ADEO Content Maturity Assessment — Universal Summary PDF Generator
|
|
Usage: python3 generate_summary.py <MARKET_ID>
|
|
|
|
Available markets:
|
|
BU_LM_FRANCE BU_LM_ITALY BU_LM_POLAND BU_LM_SPAIN
|
|
BU_LM_PORTUGAL BU_M_SPAIN BU_M_ITALY
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
# ─────────────────────────────────────────────
|
|
# PATHS & CONSTANTS
|
|
# ─────────────────────────────────────────────
|
|
BOX = Path("/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE")
|
|
DESKTOP = Path("/Users/phildore/Desktop/Adeo _Asset_Production_Summary")
|
|
|
|
PILLAR_ORDER = [
|
|
"OMNICHANNEL",
|
|
"CLIENT CENTRICITY",
|
|
"MEASUREMENT",
|
|
"TECH CAPABILITIES",
|
|
"AUTOMATION & INDUSTRIALIZATION",
|
|
"INNOVATION",
|
|
"ORGANISATION",
|
|
]
|
|
|
|
SCORE_LEVEL = {1: "Learner", 2: "Intermediate", 3: "Master", 4: "Expert"}
|
|
SCORE_COLOR = {1: "#C62828", 2: "#E65100", 3: "#2E7D32", 4: "#1B5E20"}
|
|
|
|
ACCENT = "#00CCDF"
|
|
NAVY = "#1A2B3C"
|
|
WHITE = "#FFFFFF"
|
|
BODY_GRAY = "#222222"
|
|
|
|
# ─────────────────────────────────────────────
|
|
# MARKET CONFIGURATIONS
|
|
# ─────────────────────────────────────────────
|
|
MARKETS = {
|
|
"BU_LM_FRANCE": {
|
|
"label": "Leroy Merlin France",
|
|
"short": "LM France",
|
|
"q_dir": BOX / "BU_LM_FRANCE/05_MATURITY/00_MASTER",
|
|
"out_dir": BOX / "BU_LM_FRANCE/05_MATURITY/01_OUTPUTS/PDF",
|
|
"desktop_dir": DESKTOP / "LM France ",
|
|
"filename": "BU_LM_FRANCE_Maturity_Assessment_Summary_v3.pdf",
|
|
"footer": "Leroy Merlin France · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_LM_ITALY": {
|
|
"label": "Leroy Merlin Italy",
|
|
"short": "LM Italy",
|
|
"q_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/00_MASTER",
|
|
"out_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/02_OUTPUTS/PDF",
|
|
"desktop_dir": DESKTOP / "LM Italy ",
|
|
"filename": "BU_LM_ITALY_Maturity_Assessment_Summary_v2.pdf",
|
|
"footer": "Leroy Merlin Italy · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_LM_POLAND": {
|
|
"label": "Leroy Merlin Poland",
|
|
"short": "LM Poland",
|
|
"q_dir": BOX / "BU_LM_POLAND/05_MATURITY/00_MASTER",
|
|
"out_dir": BOX / "BU_LM_POLAND/05_MATURITY/01_OUTPUTS/PDF",
|
|
"desktop_dir": DESKTOP / "LM Poland ",
|
|
"filename": "BU_LM_POLAND_Maturity_Assessment_Summary_v3.pdf",
|
|
"footer": "Leroy Merlin Poland · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_LM_SPAIN": {
|
|
"label": "Leroy Merlin Spain",
|
|
"short": "LM Spain",
|
|
"q_dir": BOX / "BU_LM_SPAIN/06_MATURITY/02_VERSION/V4",
|
|
"out_dir": BOX / "BU_LM_SPAIN/06_MATURITY/01_OUTPUT/PDF/V4",
|
|
"desktop_dir": DESKTOP / "M Spain ",
|
|
"filename": "BU_LM_SPAIN_Maturity_Assessment_Summary_V5.pdf",
|
|
"footer": "Leroy Merlin Spain · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_LM_PORTUGAL": {
|
|
"label": "Leroy Merlin Portugal",
|
|
"short": "LM Portugal",
|
|
"q_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/00_MASTER",
|
|
"out_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/02_OUTPUTS/PDF/Round_3",
|
|
"desktop_dir": DESKTOP / "LM Portugal ",
|
|
"filename": "BU_LM_PORTUGAL_Maturity_Assessment_Summary_v3.pdf",
|
|
"footer": "Leroy Merlin Portugal · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_M_SPAIN": {
|
|
"label": "Obramat Spain",
|
|
"short": "Obramat Spain",
|
|
"q_dir": BOX / "BU_M_SPAIN/06_QUESTIONS/MASTER",
|
|
"out_dir": BOX / "BU_M_SPAIN/08_ASSESSMENT/02_Round_2/PDF",
|
|
"desktop_dir": DESKTOP / "M Spain ",
|
|
"filename": "BU_M_SPAIN_Maturity_Assessment_Summary_v3.pdf",
|
|
"footer": "Obramat Spain · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
"BU_M_ITALY": {
|
|
"label": "Tecnomat Italy",
|
|
"short": "Tecnomat Italy",
|
|
"q_dir": BOX / "BU_M_ITALY",
|
|
"out_dir": BOX / "BU_M_ITALY/06_ASSESSMENT/02_Round_2/PDF",
|
|
"desktop_dir": DESKTOP / "M Italy ",
|
|
"filename": "BU_M_ITALY_Maturity_Assessment_Summary_v3.pdf",
|
|
"footer": "Tecnomat Italy · Content Maturity Assessment · April 2026 · Confidential",
|
|
},
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# FLEXIBLE SECTION PARSERS
|
|
# ─────────────────────────────────────────────
|
|
def parse_numbered_bullets(block: str) -> list[tuple[str, str]]:
|
|
"""Extract `1. **Title:** body` style bullets from a text block."""
|
|
bullets = re.findall(
|
|
r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)",
|
|
block,
|
|
re.DOTALL | re.MULTILINE,
|
|
)
|
|
return [(t.strip(), b.strip()) for t, b in bullets]
|
|
|
|
|
|
def extract_strengths(text: str) -> list[tuple[str, str]]:
|
|
"""Find Strengths bullets from any known section format."""
|
|
# Top-level ## Strengths
|
|
m = re.search(r"##\s+Strengths\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL)
|
|
if m:
|
|
bullets = parse_numbered_bullets(m.group(1))
|
|
if bullets:
|
|
return bullets
|
|
|
|
# Nested ### Strengths (under a custom analysis section)
|
|
m = re.search(r"###\s+Strengths\s*\n(.*?)(?=\n##\s|\n###\s|\Z)", text, re.DOTALL)
|
|
if m:
|
|
bullets = parse_numbered_bullets(m.group(1))
|
|
if bullets:
|
|
return bullets
|
|
|
|
# ## Main Analysis used in place of Strengths (LM Spain V4 style)
|
|
m = re.search(r"##\s+Main Analysis\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL)
|
|
if m:
|
|
bullets = parse_numbered_bullets(m.group(1))
|
|
if bullets:
|
|
return bullets
|
|
# If no numbered bullets, grab ### subheadings as bullet items
|
|
subs = re.findall(
|
|
r"###\s+(.+?)\n(.*?)(?=\n###\s|\n##\s|\Z)",
|
|
m.group(1),
|
|
re.DOTALL,
|
|
)
|
|
result = []
|
|
for title, body in subs:
|
|
body_clean = body.strip()
|
|
if body_clean:
|
|
result.append((title.strip(), body_clean))
|
|
if result:
|
|
return result
|
|
|
|
return []
|
|
|
|
|
|
def extract_gaps(text: str) -> list[tuple[str, str]]:
|
|
"""Find Gaps bullets from any known section format."""
|
|
patterns = [
|
|
r"##\s+Gaps\s+or\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
|
|
r"##\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
|
|
r"##\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
|
|
r"##\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\Z)",
|
|
r"###\s+Gaps(?:\s+or\s+Minor\s+Gaps)?\s*\n(.*?)(?=\n##\s|\n###\s|\Z)",
|
|
r"###\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\n###\s|\Z)",
|
|
]
|
|
for pat in patterns:
|
|
m = re.search(pat, text, re.DOTALL)
|
|
if m:
|
|
bullets = parse_numbered_bullets(m.group(1))
|
|
if bullets:
|
|
return bullets
|
|
return []
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Q FILE PARSING
|
|
# ─────────────────────────────────────────────
|
|
def parse_qfile(path: Path) -> dict:
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
m = re.search(r"^#\s+Question\s+(\d+)\s+Analysis:\s*(.+)$", text, re.MULTILINE)
|
|
if not m:
|
|
raise ValueError(f"Cannot parse title in {path.name}")
|
|
q_num = int(m.group(1))
|
|
topic = m.group(2).strip()
|
|
|
|
m = re.search(r"\*\*Pillar:\*\*\s*(.+)", text)
|
|
pillar = m.group(1).strip() if m else "UNKNOWN"
|
|
|
|
m = re.search(r"\*\*Score:\s*(\d(?:\.\d+)?)", text)
|
|
score = float(m.group(1)) if m else 0.0
|
|
|
|
m = re.search(
|
|
r"##\s+Executive Summary\s*\n(.*?)(?=\n##\s|\Z)",
|
|
text,
|
|
re.DOTALL,
|
|
)
|
|
exec_summary = m.group(1).strip() if m else ""
|
|
|
|
return {
|
|
"num": q_num,
|
|
"topic": topic,
|
|
"pillar": pillar,
|
|
"score": score,
|
|
"level": SCORE_LEVEL.get(max(1, min(4, round(score))), "Unknown"),
|
|
"exec_summary": exec_summary,
|
|
"strengths": extract_strengths(text),
|
|
"gaps": extract_gaps(text),
|
|
}
|
|
|
|
|
|
def load_questions(q_dir: Path) -> list[dict]:
|
|
"""Load all Q files, deduplicating by question number (keep highest-numbered filename)."""
|
|
files = sorted(
|
|
[
|
|
f for f in q_dir.glob("Question_*_Analysis.md")
|
|
if not any(x in f.name.lower() for x in ("original", "test"))
|
|
],
|
|
key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)),
|
|
)
|
|
|
|
# Deduplicate: if two files have the same q_num, last one wins
|
|
seen: dict[int, Path] = {}
|
|
for f in files:
|
|
n = int(re.search(r"Question_(\d+)_Analysis", f.name).group(1))
|
|
seen[n] = f
|
|
deduped = sorted(seen.values(), key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)))
|
|
|
|
questions = []
|
|
for f in deduped:
|
|
try:
|
|
q = parse_qfile(f)
|
|
questions.append(q)
|
|
except Exception as e:
|
|
print(f" WARNING: could not parse {f.name}: {e}")
|
|
print(f" Loaded {len(questions)} questions from {q_dir}")
|
|
return questions
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# PDF IMPORTS
|
|
# ─────────────────────────────────────────────
|
|
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, TA_RIGHT
|
|
from reportlab.platypus import (
|
|
BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer,
|
|
PageBreak, Table, TableStyle, KeepTogether,
|
|
)
|
|
from reportlab.platypus.flowables import HRFlowable, Flowable
|
|
|
|
PAGE_W, PAGE_H = A4
|
|
ML = MR = 22 * mm
|
|
MT = MB = 26 * mm
|
|
CONTENT_W = PAGE_W - ML - MR
|
|
|
|
|
|
def hex_color(h: str):
|
|
return colors.HexColor(h.lstrip("#") and f"#{h.lstrip('#')}")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# PAGE DECORATORS
|
|
# ─────────────────────────────────────────────
|
|
def make_footer_fn(footer_text: str):
|
|
def _draw(canvas, doc):
|
|
canvas.saveState()
|
|
page = canvas.getPageNumber()
|
|
canvas.setFillColor(hex_color(NAVY))
|
|
canvas.rect(0, 0, PAGE_W, MB * 0.7, fill=1, stroke=0)
|
|
canvas.setFont("Helvetica", 7.5)
|
|
canvas.setFillColor(colors.white)
|
|
canvas.drawCentredString(PAGE_W / 2, MB * 0.25, footer_text)
|
|
if page >= 2:
|
|
canvas.setFillColor(hex_color(NAVY))
|
|
canvas.setFont("Helvetica-Bold", 8)
|
|
canvas.drawRightString(PAGE_W - MR, PAGE_H - MT * 0.6, str(page))
|
|
canvas.restoreState()
|
|
return _draw
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# STYLES
|
|
# ─────────────────────────────────────────────
|
|
def make_styles():
|
|
s = {}
|
|
s["body"] = ParagraphStyle("body", fontName="Helvetica", fontSize=9, leading=14,
|
|
textColor=hex_color(BODY_GRAY), alignment=TA_JUSTIFY)
|
|
s["body_left"] = ParagraphStyle("body_left", parent=s["body"], alignment=TA_LEFT)
|
|
s["bold"] = ParagraphStyle("bold", parent=s["body"], fontName="Helvetica-Bold")
|
|
s["small"] = ParagraphStyle("small", fontName="Helvetica", fontSize=7.5, leading=11,
|
|
textColor=hex_color(BODY_GRAY), alignment=TA_LEFT)
|
|
s["cover_title"] = ParagraphStyle("cover_title", fontName="Helvetica-Bold", fontSize=32,
|
|
leading=40, textColor=hex_color(WHITE), alignment=TA_LEFT, spaceAfter=6)
|
|
s["cover_subtitle"] = ParagraphStyle("cover_subtitle", fontName="Helvetica", fontSize=13,
|
|
leading=18, textColor=hex_color(ACCENT), alignment=TA_LEFT)
|
|
s["section_h1"] = ParagraphStyle("section_h1", fontName="Helvetica-Bold", fontSize=16,
|
|
leading=22, textColor=hex_color(NAVY), spaceBefore=8, spaceAfter=6, alignment=TA_LEFT)
|
|
s["exec_summary"] = ParagraphStyle("exec_summary", parent=s["body"],
|
|
leftIndent=4, rightIndent=4, spaceBefore=2, spaceAfter=6)
|
|
s["bullet_title"] = ParagraphStyle("bullet_title", fontName="Helvetica-Bold", fontSize=9,
|
|
leading=13, textColor=hex_color(NAVY), leftIndent=10, firstLineIndent=-10)
|
|
s["bullet_body"] = ParagraphStyle("bullet_body", parent=s["body"],
|
|
leftIndent=10, firstLineIndent=0, spaceBefore=1, spaceAfter=4)
|
|
s["pillar_label"] = ParagraphStyle("pillar_label", fontName="Helvetica-Bold", fontSize=8,
|
|
leading=12, textColor=hex_color(ACCENT), spaceBefore=8, spaceAfter=2)
|
|
s["center"] = ParagraphStyle("center", fontName="Helvetica", fontSize=9, leading=13,
|
|
textColor=hex_color(BODY_GRAY), alignment=TA_CENTER)
|
|
return s
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# FLOWABLE HELPERS
|
|
# ─────────────────────────────────────────────
|
|
class CoverBanner(Flowable):
|
|
"""Navy banner that contains the market title and subtitle — fully self-contained."""
|
|
def __init__(self, label: str):
|
|
super().__init__()
|
|
self.label = label
|
|
self._v_pad = 18 * mm
|
|
self._title_style = ParagraphStyle("ct", fontName="Helvetica-Bold", fontSize=32,
|
|
leading=40, textColor=colors.white)
|
|
self._sub_style = ParagraphStyle("cs", fontName="Helvetica", fontSize=13,
|
|
leading=18, textColor=hex_color(ACCENT))
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
self.width = availWidth
|
|
self._tp = Paragraph(self.label, self._title_style)
|
|
self._sp = Paragraph(
|
|
"Content Maturity Assessment · April 2026 · Confidential",
|
|
self._sub_style,
|
|
)
|
|
_, self._th = self._tp.wrap(availWidth, 1000)
|
|
_, self._sh = self._sp.wrap(availWidth, 1000)
|
|
self.banner_h = self._v_pad + self._th + 6 + self._sh + self._v_pad
|
|
return (availWidth, self.banner_h)
|
|
|
|
def draw(self):
|
|
c = self.canv
|
|
c.saveState()
|
|
c.setFillColor(hex_color(NAVY))
|
|
c.rect(-ML, 0, PAGE_W, self.banner_h, fill=1, stroke=0)
|
|
c.restoreState()
|
|
sub_y = self._v_pad
|
|
title_y = sub_y + self._sh + 6
|
|
self._sp.drawOn(self.canv, 0, sub_y)
|
|
self._tp.drawOn(self.canv, 0, title_y)
|
|
|
|
|
|
class AccentLine(Flowable):
|
|
def __init__(self, width=None, thickness=3):
|
|
super().__init__()
|
|
self.line_width = width or CONTENT_W
|
|
self.thickness = thickness
|
|
|
|
def draw(self):
|
|
self.canv.saveState()
|
|
self.canv.setStrokeColor(hex_color(ACCENT))
|
|
self.canv.setLineWidth(self.thickness)
|
|
self.canv.line(0, 0, self.line_width, 0)
|
|
self.canv.restoreState()
|
|
|
|
def wrap(self, *args):
|
|
return (self.line_width, self.thickness + 2)
|
|
|
|
|
|
def pillar_header(pillar: str, avg: float, styles) -> list:
|
|
level_int = max(1, min(4, round(avg)))
|
|
heading_style = ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13,
|
|
leading=16, textColor=colors.white)
|
|
score_style = ParagraphStyle("ps", fontName="Helvetica-Bold", fontSize=13,
|
|
leading=16, textColor=colors.white, alignment=TA_RIGHT)
|
|
lvl_name = SCORE_LEVEL.get(level_int, "")
|
|
data = [[Paragraph(pillar, heading_style),
|
|
Paragraph(f"{avg:.2f} — {lvl_name}", score_style)]]
|
|
t = Table(data, colWidths=[CONTENT_W * 0.7, CONTENT_W * 0.3])
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, -1), hex_color(ACCENT)),
|
|
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
|
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 8),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
|
("LEFTPADDING", (0, 0), (0, -1), 10),
|
|
("RIGHTPADDING", (-1, 0), (-1, -1), 10),
|
|
]))
|
|
return [t, Spacer(1, 4)]
|
|
|
|
|
|
def question_score_table(questions: list[dict], styles) -> Table:
|
|
header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
|
|
leading=11, textColor=colors.white, alignment=TA_CENTER)
|
|
cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=8,
|
|
leading=11, textColor=hex_color(BODY_GRAY), alignment=TA_LEFT)
|
|
cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER)
|
|
|
|
rows = [[Paragraph("Q#", header_style), Paragraph("Topic", header_style),
|
|
Paragraph("Score", header_style), Paragraph("Level", header_style)]]
|
|
for q in questions:
|
|
level_int = max(1, min(4, round(q["score"])))
|
|
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
|
|
sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=8,
|
|
leading=11, textColor=colors.white, alignment=TA_CENTER)
|
|
lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=8,
|
|
leading=11, textColor=colors.white, alignment=TA_CENTER)
|
|
rows.append([
|
|
Paragraph(str(q["num"]), cell_center),
|
|
Paragraph(q["topic"], cell_style),
|
|
Paragraph(str(int(q["score"]) if q["score"] == int(q["score"]) else q["score"]), sc_style),
|
|
Paragraph(q["level"], lv_style),
|
|
])
|
|
col_w = [12 * mm, CONTENT_W - 12 * mm - 20 * mm - 28 * mm, 20 * mm, 28 * mm]
|
|
t = Table(rows, colWidths=col_w, repeatRows=1)
|
|
ts = [
|
|
("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F5F5F5")]),
|
|
("GRID", (0, 0), (-1, -1), 0.3, hex_color("#DDDDDD")),
|
|
]
|
|
for i, q in enumerate(questions, start=1):
|
|
level_int = max(1, min(4, round(q["score"])))
|
|
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
|
|
ts.append(("BACKGROUND", (2, i), (3, i), clr))
|
|
ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white))
|
|
t.setStyle(TableStyle(ts))
|
|
return t
|
|
|
|
|
|
def cover_pillar_table(pillars_data: list[dict], styles) -> Table:
|
|
header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=9,
|
|
leading=12, textColor=colors.white)
|
|
cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=9, leading=12,
|
|
textColor=hex_color(BODY_GRAY))
|
|
cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER)
|
|
cell_bold = ParagraphStyle("td_b", parent=cell_style, fontName="Helvetica-Bold")
|
|
|
|
rows = [[Paragraph("Pillar", header_style), Paragraph("Qs", header_style),
|
|
Paragraph("Avg Score", header_style), Paragraph("Level", header_style),
|
|
Paragraph("Min", header_style), Paragraph("Max", header_style)]]
|
|
for p in pillars_data:
|
|
level_int = max(1, min(4, round(p["avg"])))
|
|
sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=9,
|
|
leading=12, textColor=colors.white, alignment=TA_CENTER)
|
|
lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=9,
|
|
leading=12, textColor=colors.white, alignment=TA_CENTER)
|
|
rows.append([
|
|
Paragraph(p["name"], cell_bold),
|
|
Paragraph(str(p["count"]), cell_center),
|
|
Paragraph(f"{p['avg']:.2f}", sc_style),
|
|
Paragraph(p["level"], lv_style),
|
|
Paragraph(str(int(p["min"]) if p["min"] == int(p["min"]) else p["min"]), cell_center),
|
|
Paragraph(str(int(p["max"]) if p["max"] == int(p["max"]) else p["max"]), cell_center),
|
|
])
|
|
col_w = [CONTENT_W * 0.32, CONTENT_W * 0.08, CONTENT_W * 0.18,
|
|
CONTENT_W * 0.18, CONTENT_W * 0.12, CONTENT_W * 0.12]
|
|
t = Table(rows, colWidths=col_w, repeatRows=1)
|
|
ts = [
|
|
("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F0FAFB")]),
|
|
("GRID", (0, 0), (-1, -1), 0.3, hex_color("#CCCCCC")),
|
|
]
|
|
for i, p in enumerate(pillars_data, start=1):
|
|
level_int = max(1, min(4, round(p["avg"])))
|
|
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
|
|
ts.append(("BACKGROUND", (2, i), (3, i), clr))
|
|
ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white))
|
|
t.setStyle(TableStyle(ts))
|
|
return t
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# MAIN PDF BUILD
|
|
# ─────────────────────────────────────────────
|
|
def build_pdf(questions: list[dict], output_path: Path, market_cfg: dict):
|
|
styles = make_styles()
|
|
label = market_cfg["label"]
|
|
footer_fn = make_footer_fn(market_cfg["footer"])
|
|
|
|
# Group by pillar
|
|
pillar_map: dict[str, list[dict]] = {p: [] for p in PILLAR_ORDER}
|
|
for q in questions:
|
|
matched = False
|
|
p_upper = q["pillar"].upper().strip()
|
|
for p in PILLAR_ORDER:
|
|
if p_upper == p:
|
|
pillar_map[p].append(q)
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
for p in PILLAR_ORDER:
|
|
if p in p_upper:
|
|
pillar_map[p].append(q)
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
print(f" WARNING: Q{q['num']} pillar '{q['pillar']}' not recognised")
|
|
|
|
def pillar_stats(qs):
|
|
if not qs:
|
|
return {"avg": 0, "count": 0, "min": 0, "max": 0, "level": "N/A"}
|
|
scores = [q["score"] for q in qs]
|
|
avg = sum(scores) / len(scores)
|
|
return {
|
|
"avg": avg,
|
|
"count": len(qs),
|
|
"min": min(scores),
|
|
"max": max(scores),
|
|
"level": SCORE_LEVEL.get(max(1, min(4, round(avg))), "Unknown"),
|
|
}
|
|
|
|
pillars_data = []
|
|
for p in PILLAR_ORDER:
|
|
stats = pillar_stats(pillar_map[p])
|
|
stats["name"] = p
|
|
stats["questions"] = pillar_map[p]
|
|
pillars_data.append(stats)
|
|
|
|
all_scores = [q["score"] for q in questions]
|
|
overall = sum(all_scores) / len(all_scores) if all_scores else 0
|
|
overall_level_int = max(1, min(4, round(overall)))
|
|
overall_level = SCORE_LEVEL.get(overall_level_int, "Unknown")
|
|
|
|
print(f" Overall score: {overall:.2f} ({overall_level})")
|
|
for p in pillars_data:
|
|
if p["count"] > 0:
|
|
print(f" {p['name']}: {p['avg']:.2f} ({p['level']}) — {p['count']} Qs")
|
|
|
|
# Document setup
|
|
doc = BaseDocTemplate(
|
|
str(output_path),
|
|
pagesize=A4,
|
|
leftMargin=ML, rightMargin=MR, topMargin=MT, bottomMargin=MB,
|
|
title=f"{label} — Content Maturity Assessment",
|
|
author="Oliver Agency",
|
|
)
|
|
frame = Frame(ML, MB, CONTENT_W, PAGE_H - MT - MB, id="normal")
|
|
template = PageTemplate(id="main", frames=[frame], onPage=footer_fn)
|
|
doc.addPageTemplates([template])
|
|
|
|
story = []
|
|
|
|
# ── COVER ──
|
|
story.append(CoverBanner(label))
|
|
story.append(Spacer(1, 8))
|
|
story.append(AccentLine(CONTENT_W, 3))
|
|
story.append(Spacer(1, 10))
|
|
|
|
# Overall score badge
|
|
overall_clr = hex_color(SCORE_COLOR.get(overall_level_int, SCORE_COLOR[2]))
|
|
badge_label_style = ParagraphStyle("bl", fontName="Helvetica-Bold", fontSize=10,
|
|
leading=14, textColor=hex_color(NAVY))
|
|
badge_score_style = ParagraphStyle("bs", fontName="Helvetica-Bold", fontSize=26,
|
|
leading=32, textColor=colors.white, alignment=TA_CENTER)
|
|
badge_level_style = ParagraphStyle("bls", fontName="Helvetica-Bold", fontSize=11,
|
|
leading=14, textColor=colors.white, alignment=TA_CENTER)
|
|
badge_sub_style = ParagraphStyle("bsub", fontName="Helvetica", fontSize=8,
|
|
leading=11, textColor=colors.white, alignment=TA_CENTER)
|
|
badge_data = [
|
|
[Paragraph(f"{overall:.2f}", badge_score_style)],
|
|
[Paragraph(f"Overall — {overall_level}", badge_level_style)],
|
|
[Paragraph("59 questions across 7 pillars", badge_sub_style)],
|
|
]
|
|
badge_t = Table(badge_data, colWidths=[55 * mm])
|
|
badge_t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, -1), overall_clr),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("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),
|
|
("ROUNDEDCORNERS", [5]),
|
|
]))
|
|
cover_label = Paragraph("Overall Maturity Score", badge_label_style)
|
|
badge_row = Table([[cover_label, badge_t]], colWidths=[CONTENT_W - 65 * mm, 65 * mm])
|
|
badge_row.setStyle(TableStyle([
|
|
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
|
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
|
("TOPPADDING", (0, 0), (-1, -1), 0),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
|
]))
|
|
story.append(badge_row)
|
|
story.append(Spacer(1, 12))
|
|
|
|
story.append(Paragraph("Pillar Summary", ParagraphStyle(
|
|
"pt", fontName="Helvetica-Bold", fontSize=12, leading=16,
|
|
textColor=hex_color(NAVY), spaceAfter=6)))
|
|
story.append(cover_pillar_table(pillars_data, styles))
|
|
story.append(PageBreak())
|
|
|
|
# ── PER-PILLAR SECTIONS ──
|
|
for pillar in pillars_data:
|
|
p_qs = pillar["questions"]
|
|
if not p_qs:
|
|
continue
|
|
|
|
story.extend(pillar_header(pillar["name"], pillar["avg"], styles))
|
|
story.append(question_score_table(p_qs, styles))
|
|
story.append(Spacer(1, 10))
|
|
|
|
for q in p_qs:
|
|
level_int = max(1, min(4, round(q["score"])))
|
|
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
|
|
|
|
chip_style = ParagraphStyle("chip", fontName="Helvetica-Bold", fontSize=8,
|
|
leading=11, textColor=colors.white, alignment=TA_CENTER)
|
|
topic_style = ParagraphStyle("qt", fontName="Helvetica-Bold", fontSize=10,
|
|
leading=14, textColor=hex_color(NAVY))
|
|
|
|
score_disp = int(q["score"]) if q["score"] == int(q["score"]) else q["score"]
|
|
chip_data = [[Paragraph(f"Q{q['num']} {score_disp} {q['level']}", chip_style)]]
|
|
chip_t = Table(chip_data, colWidths=[38 * mm])
|
|
chip_t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, -1), clr),
|
|
("ALIGN", (0, 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),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
|
("ROUNDEDCORNERS", [3]),
|
|
]))
|
|
topic_para = Paragraph(q["topic"], topic_style)
|
|
q_header = Table([[chip_t, topic_para]], colWidths=[40 * mm, CONTENT_W - 40 * mm])
|
|
q_header.setStyle(TableStyle([
|
|
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
|
("ALIGN", (1, 0), (1, 0), "LEFT"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
|
("TOPPADDING", (0, 0), (-1, -1), 3),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
|
]))
|
|
|
|
exec_text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", q["exec_summary"])
|
|
exec_para = Paragraph(exec_text, styles["exec_summary"])
|
|
|
|
block = KeepTogether([
|
|
Spacer(1, 6),
|
|
q_header,
|
|
Spacer(1, 3),
|
|
exec_para,
|
|
HRFlowable(width=CONTENT_W, thickness=0.5,
|
|
color=hex_color("#DDDDDD"), spaceAfter=4),
|
|
])
|
|
story.append(block)
|
|
|
|
story.append(PageBreak())
|
|
|
|
# ── STRENGTHS SECTION ──
|
|
story.append(Paragraph("Strengths", styles["section_h1"]))
|
|
story.append(AccentLine(CONTENT_W * 0.4, 2))
|
|
story.append(Spacer(1, 8))
|
|
|
|
strengths_found = False
|
|
for pillar in pillars_data:
|
|
p_qs_with_strengths = [q for q in pillar["questions"] if q["strengths"]]
|
|
if not p_qs_with_strengths:
|
|
continue
|
|
strengths_found = True
|
|
story.append(Paragraph(pillar["name"], styles["pillar_label"]))
|
|
for q in p_qs_with_strengths:
|
|
for title, body in q["strengths"]:
|
|
body_clean = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", body.strip())
|
|
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
|
|
styles["bullet_title"]))
|
|
story.append(Paragraph(f"<b>{title}:</b> {body_clean}",
|
|
styles["bullet_body"]))
|
|
|
|
if not strengths_found:
|
|
story.append(Paragraph("No structured strengths identified in Q files.",
|
|
styles["body_left"]))
|
|
|
|
story.append(PageBreak())
|
|
|
|
# ── GAPS SECTION ──
|
|
story.append(Paragraph("Gaps", styles["section_h1"]))
|
|
story.append(AccentLine(CONTENT_W * 0.4, 2))
|
|
story.append(Spacer(1, 8))
|
|
|
|
gaps_found = False
|
|
for pillar in pillars_data:
|
|
p_qs_with_gaps = [q for q in pillar["questions"] if q["gaps"]]
|
|
if not p_qs_with_gaps:
|
|
continue
|
|
gaps_found = True
|
|
story.append(Paragraph(pillar["name"], styles["pillar_label"]))
|
|
for q in p_qs_with_gaps:
|
|
for title, body in q["gaps"]:
|
|
body_clean = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", body.strip())
|
|
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
|
|
styles["bullet_title"]))
|
|
story.append(Paragraph(f"<b>{title}:</b> {body_clean}",
|
|
styles["bullet_body"]))
|
|
|
|
if not gaps_found:
|
|
story.append(Paragraph("No structured gaps identified in Q files.",
|
|
styles["body_left"]))
|
|
|
|
doc.build(story)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# ENTRY POINT
|
|
# ─────────────────────────────────────────────
|
|
def main():
|
|
if len(sys.argv) < 2 or sys.argv[1] not in MARKETS:
|
|
print("Usage: python3 generate_summary.py <MARKET_ID>")
|
|
print(f"Markets: {', '.join(MARKETS.keys())}")
|
|
sys.exit(1)
|
|
|
|
market_id = sys.argv[1]
|
|
cfg = MARKETS[market_id]
|
|
|
|
print(f"\n{market_id} — Maturity Assessment PDF Generator")
|
|
print("=" * 55)
|
|
|
|
# Validate Q dir
|
|
if not cfg["q_dir"].exists():
|
|
print(f"ERROR: Q directory not found:\n {cfg['q_dir']}")
|
|
sys.exit(1)
|
|
|
|
# Create output dirs
|
|
cfg["out_dir"].mkdir(parents=True, exist_ok=True)
|
|
cfg["desktop_dir"].mkdir(parents=True, exist_ok=True)
|
|
|
|
output_path = cfg["out_dir"] / cfg["filename"]
|
|
|
|
print(f"\nParsing question files from:\n {cfg['q_dir']}")
|
|
questions = load_questions(cfg["q_dir"])
|
|
if not questions:
|
|
print("ERROR: No questions loaded.")
|
|
sys.exit(1)
|
|
|
|
# Strengths/gaps summary
|
|
s_count = sum(1 for q in questions if q["strengths"])
|
|
g_count = sum(1 for q in questions if q["gaps"])
|
|
print(f" Questions with strengths: {s_count}/{len(questions)}")
|
|
print(f" Questions with gaps: {g_count}/{len(questions)}")
|
|
|
|
print(f"\nBuilding PDF → {output_path}")
|
|
build_pdf(questions, output_path, cfg)
|
|
|
|
size_kb = output_path.stat().st_size / 1024
|
|
print(f"\nOutput PDF: {output_path}")
|
|
print(f"File size: {size_kb:.1f} KB")
|
|
|
|
dest = cfg["desktop_dir"] / cfg["filename"]
|
|
shutil.copy2(output_path, dest)
|
|
print(f"Copied to: {dest}")
|
|
print("\nDone.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|