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>
This commit is contained in:
commit
1fd3dc00ae
14 changed files with 5944 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
.deployed
|
||||
tmp_uploads/
|
||||
clients/*/data.json
|
||||
clients/adeo/exports/
|
||||
BIN
clients/adeo/Logo.png
Normal file
BIN
clients/adeo/Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
47
clients/adeo/config.json
Normal file
47
clients/adeo/config.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"id": "adeo",
|
||||
"name": "ADEO",
|
||||
"description": "Content Maturity Assessment",
|
||||
"accent_color": "#00CCDF",
|
||||
"logo": "logo.png",
|
||||
"scoring": {
|
||||
"min": 1,
|
||||
"max": 4,
|
||||
"labels": {
|
||||
"1": "Learner",
|
||||
"2": "Intermediate",
|
||||
"3": "Master",
|
||||
"4": "Expert"
|
||||
}
|
||||
},
|
||||
"pillars": [
|
||||
"OMNICHANNEL",
|
||||
"CLIENT CENTRICITY",
|
||||
"MEASUREMENT",
|
||||
"TECH CAPABILITIES",
|
||||
"AUTOMATION & INDUSTRIALIZATION",
|
||||
"INNOVATION",
|
||||
"ORGANISATION"
|
||||
],
|
||||
"entity_label": "Markets",
|
||||
"entity_label_singular": "Market",
|
||||
"about": {
|
||||
"summary": "A 59-question audit across 7 pillars that assesses how sophisticated each Business Unit is at producing and managing marketing content — telling ADEO exactly where each brand stands and where to focus investment.",
|
||||
"question_count": 59,
|
||||
"pillar_descriptions": {
|
||||
"OMNICHANNEL": "Does content work consistently across TV, social, print, digital, etc.?",
|
||||
"CLIENT CENTRICITY": "Is content tailored to specific customer types and behaviours?",
|
||||
"MEASUREMENT": "Do they properly track how content performs?",
|
||||
"TECH CAPABILITIES": "What tools and platforms do they use (e.g. DAM, DCO, Figma)?",
|
||||
"AUTOMATION & INDUSTRIALIZATION": "How automated is their content production workflow?",
|
||||
"INNOVATION": "Are they using AI, testing new formats, and experimenting?",
|
||||
"ORGANISATION": "How well-structured is the team, processes, and knowledge-sharing?"
|
||||
},
|
||||
"scoring_descriptions": {
|
||||
"1": "Nothing systematic — things happen on an ad hoc basis or not at all",
|
||||
"2": "Some capability exists, but it's inconsistent or undocumented",
|
||||
"3": "Systematic, consistent, and well-evidenced capability",
|
||||
"4": "Best-in-class — automated, measured, and continuously improved"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
clients/adeo/deliverables.json
Normal file
34
clients/adeo/deliverables.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"BU_LM_FRANCE": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM France /BU_LM_FRANCE_Maturity_Assessment_Summary_v3.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM France /BU_LM_FRANCE_Maturity_Scores_Consolidated_v2.xlsx"
|
||||
},
|
||||
"BU_LM_ITALY": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Italy /BU_LM_ITALY_Maturity_Assessment_Summary_v2.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Italy /BU_LM_ITALY_Maturity_Scores_Consolidated_v1.csv"
|
||||
},
|
||||
"BU_LM_POLAND": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Poland /BU_LM_POLAND_Maturity_Assessment_Summary_v3.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Poland /BU_LM_POLAND_Maturity_Scores_Consolidated_v2.xlsx"
|
||||
},
|
||||
"BU_LM_SPAIN": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_LM_SPAIN_Maturity_Assessment_Summary_V5.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_LM_SPAIN_Maturity_Scores_Consolidated_v4.xlsx"
|
||||
},
|
||||
"BU_LM_PORTUGAL": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Portugal /BU_LM_PORTUGAL_Maturity_Assessment_Summary_v3.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Portugal /BU_LM_PORTUGAL_Maturity_Scores_Consolidated_v6.xlsx"
|
||||
},
|
||||
"BU_M_SPAIN": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_M_SPAIN_Maturity_Assessment_Summary_v3.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_M_SPAIN_Maturity_Scores_Consolidated_v2.xlsx"
|
||||
},
|
||||
"BU_M_BRAZIL": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Brazil /BU_M_BRAZIL_Maturity_Assessment_Summary_R2_v5.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Brazil /BU_M_BRAZIL_Maturity_Scores_Consolidated_v4.xlsx"
|
||||
},
|
||||
"BU_M_ITALY": {
|
||||
"pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Italy /BU_M_ITALY_Maturity_Assessment_Summary_v3.pdf",
|
||||
"xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Italy /BU_M_ITALY_Maturity_Scores_Consolidated_v2.xlsx"
|
||||
}
|
||||
}
|
||||
372
convert_data.py
Normal file
372
convert_data.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Maturity Tool — Data Converter
|
||||
Usage: python3 convert_data.py adeo
|
||||
|
||||
Reads source CSVs/XLSXs for the given client and writes:
|
||||
clients/{client}/data.json
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
BOX_ROOT = Path("/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE")
|
||||
|
||||
# ── Score helpers ──────────────────────────────────────────────────────────────
|
||||
def parse_score(s):
|
||||
"""Extract numeric score from strings like '3', '3.5', '3 (Master)'. Returns None if unparseable."""
|
||||
m = re.search(r"\d+(?:\.\d+)?", str(s))
|
||||
return float(m.group()) if m else None
|
||||
|
||||
def score_to_level(score, scoring):
|
||||
"""Map a numeric score to 1-based tier index within the configured scale."""
|
||||
if score is None:
|
||||
return None
|
||||
mn, mx = scoring["min"], scoring["max"]
|
||||
num_levels = len(scoring["labels"])
|
||||
# Evenly divide the scale into num_levels buckets
|
||||
bucket = (mx - mn) / num_levels
|
||||
for i in range(1, num_levels + 1):
|
||||
if score <= mn + bucket * i:
|
||||
return i
|
||||
return num_levels
|
||||
|
||||
def level_label(level, scoring):
|
||||
if level is None:
|
||||
return ""
|
||||
return scoring["labels"].get(str(level), "")
|
||||
|
||||
# ── Column normalisation ───────────────────────────────────────────────────────
|
||||
def normalise_row(raw, schema):
|
||||
"""Return canonical dict regardless of CSV schema variant."""
|
||||
if schema == "A":
|
||||
q_num = raw.get("Q#", "").strip()
|
||||
topic = raw.get("Question", "").strip()
|
||||
pillar = raw.get("Pillar", "").strip().upper()
|
||||
score_s = raw.get("Score", "")
|
||||
label = raw.get("Score_Label", "").strip()
|
||||
rationale = raw.get("Scoring_Rationale", "").strip()
|
||||
gaps = raw.get("Gaps_Identified", "").strip()
|
||||
refs = raw.get("Evidence_References", "").strip()
|
||||
elif schema == "B":
|
||||
q_num = raw.get("Question_Number", "").strip()
|
||||
topic = raw.get("Question_Topic", "").strip()
|
||||
pillar = raw.get("Pillar", "").strip().upper()
|
||||
score_s = raw.get("Score_Numeric", "")
|
||||
label = raw.get("Score_Label", "").strip()
|
||||
rationale = raw.get("Rationale", "").strip()
|
||||
gaps = raw.get("Gaps", "").strip()
|
||||
refs = raw.get("References_Used", "").strip()
|
||||
else:
|
||||
raise ValueError(f"Unknown schema: {schema}")
|
||||
|
||||
score_num = parse_score(score_s)
|
||||
return {
|
||||
"q_num": q_num,
|
||||
"topic": topic,
|
||||
"pillar": pillar,
|
||||
"score": score_num,
|
||||
"label": label,
|
||||
"rationale": rationale,
|
||||
"gaps": gaps,
|
||||
"refs": refs,
|
||||
}
|
||||
|
||||
def normalise_xlsx_row(row_dict, schema="B"):
|
||||
"""Same as normalise_row but input already keyed by column header (from openpyxl)."""
|
||||
return normalise_row(row_dict, schema)
|
||||
|
||||
# ── ADEO source definitions ────────────────────────────────────────────────────
|
||||
ADEO_SOURCES = [
|
||||
{
|
||||
"id": "BU_LM_FRANCE",
|
||||
"label": "Leroy Merlin France",
|
||||
"short": "LM France",
|
||||
"group": "Leroy Merlin",
|
||||
"file": BOX_ROOT / "BU_LM_FRANCE/05_MATURITY/01_OUTPUTS/CSV/BU_LM_FRANCE_Maturity_Consolidated.csv",
|
||||
"schema": "A",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_LM_ITALY",
|
||||
"label": "Leroy Merlin Italy",
|
||||
"short": "LM Italy",
|
||||
"group": "Leroy Merlin",
|
||||
"file": BOX_ROOT / "BU_LM_ITALY/06_MATURITY_GRID/02_OUTPUTS/V3/CSV/BU_LM_ITALY_Maturity_Scores_Consolidated_v1.csv",
|
||||
"schema": "B",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_LM_POLAND",
|
||||
"label": "Leroy Merlin Poland",
|
||||
"short": "LM Poland",
|
||||
"group": "Leroy Merlin",
|
||||
"file": BOX_ROOT / "BU_LM_POLAND/05_MATURITY/01_OUTPUTS/CSV/BU_LM_POLAND_Maturity_Consolidated.csv",
|
||||
"schema": "A",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_LM_SPAIN",
|
||||
"label": "Leroy Merlin Spain",
|
||||
"short": "LM Spain",
|
||||
"group": "Leroy Merlin",
|
||||
"file": BOX_ROOT / "BU_LM_SPAIN/06_MATURITY/01_OUTPUT/CSV/V4/BU_LM_SPAIN_Maturity_Scores_Consolidated_v4.xlsx",
|
||||
"schema": "B",
|
||||
"xlsx": True,
|
||||
},
|
||||
{
|
||||
"id": "BU_LM_PORTUGAL",
|
||||
"label": "Leroy Merlin Portugal",
|
||||
"short": "LM Portugal",
|
||||
"group": "Leroy Merlin",
|
||||
"file": BOX_ROOT / "BU_LM_PORTUGAL copy/05_MATURITY/02_OUTPUTS/CSV/BU_LM_PORTUGAL_Maturity_Scores_Consolidated_v5.csv",
|
||||
"schema": "B",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_M_SPAIN",
|
||||
"label": "Obramat Spain",
|
||||
"short": "Obramat Spain",
|
||||
"group": "Obramat",
|
||||
"file": BOX_ROOT / "BU_M_SPAIN/08_ASSESSMENT/02_Round_2/CSV/BU_M_SPAIN_Maturity_Scores_Consolidated_v1.csv",
|
||||
"schema": "B",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_M_BRAZIL",
|
||||
"label": "Obramax Brazil",
|
||||
"short": "Obramax Brazil",
|
||||
"group": "Obramax",
|
||||
"file": BOX_ROOT / "BU_M_BRAZIL/06_ASSESSMENT/02_Round_2/CSV/BU_M_BRAZIL_Maturity_Scores_Consolidated_v1.csv",
|
||||
"schema": "B",
|
||||
"xlsx": False,
|
||||
},
|
||||
{
|
||||
"id": "BU_M_ITALY",
|
||||
"label": "Tecnomat Italy",
|
||||
"short": "Tecnomat Italy",
|
||||
"group": "Tecnomat",
|
||||
"file": BOX_ROOT / "BU_M_ITALY/06_ASSESSMENT/02_Round_2/CSV/BU_M_ITALY_Maturity_Scores_Consolidated_v1.csv",
|
||||
"schema": "B",
|
||||
"xlsx": False,
|
||||
},
|
||||
]
|
||||
|
||||
# ── Parsers ────────────────────────────────────────────────────────────────────
|
||||
def read_csv_rows(path, schema):
|
||||
rows = []
|
||||
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||
for raw in csv.DictReader(f):
|
||||
row = normalise_row(raw, schema)
|
||||
if row["score"] is None:
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def detect_xlsx_schema(headers):
|
||||
"""Pick the right schema based on whichever Q# column name is present."""
|
||||
h = [h.lower() for h in headers]
|
||||
if "question_number" in h:
|
||||
return "B"
|
||||
if "q#" in h:
|
||||
# Spain V4 XLSX — Q#, Question Topic, Score, Level, Rationale, Gaps, References Used
|
||||
return "XLSX_A"
|
||||
return "B"
|
||||
|
||||
def normalise_xlsx_a_row(raw):
|
||||
"""Spain V4 XLSX variant: Q#, Question Topic, Score, Level, Rationale, Gaps, References Used."""
|
||||
score_s = raw.get("Score", "")
|
||||
score_num = parse_score(score_s)
|
||||
return {
|
||||
"q_num": str(raw.get("Q#", "")).strip(),
|
||||
"topic": raw.get("Question Topic", "").strip(),
|
||||
"pillar": raw.get("Pillar", "").strip().upper(),
|
||||
"score": score_num,
|
||||
"label": raw.get("Level", "").strip(),
|
||||
"rationale": raw.get("Rationale", "").strip(),
|
||||
"gaps": raw.get("Gaps", "").strip(),
|
||||
"refs": raw.get("References Used", "").strip(),
|
||||
}
|
||||
|
||||
def read_xlsx_rows(path, schema):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
print("ERROR: openpyxl required for XLSX files. Run: pip install openpyxl")
|
||||
sys.exit(1)
|
||||
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
headers = None
|
||||
detected_schema = schema
|
||||
rows = []
|
||||
for excel_row in ws.iter_rows(values_only=True):
|
||||
if headers is None:
|
||||
headers = [str(c).strip() if c else "" for c in excel_row]
|
||||
detected_schema = detect_xlsx_schema(headers)
|
||||
continue
|
||||
raw = dict(zip(headers, [str(c).strip() if c is not None else "" for c in excel_row]))
|
||||
if detected_schema == "XLSX_A":
|
||||
row = normalise_xlsx_a_row(raw)
|
||||
else:
|
||||
row = normalise_xlsx_row(raw, detected_schema)
|
||||
if row["score"] is None:
|
||||
continue
|
||||
rows.append(row)
|
||||
wb.close()
|
||||
return rows
|
||||
|
||||
def parse_entity(source, scoring, pillar_order):
|
||||
path = source["file"]
|
||||
if not path.exists():
|
||||
print(f" WARNING: file not found — {path}")
|
||||
return None
|
||||
|
||||
rows = read_xlsx_rows(path, source["schema"]) if source["xlsx"] else read_csv_rows(path, source["schema"])
|
||||
|
||||
by_pillar = defaultdict(list)
|
||||
for row in rows:
|
||||
by_pillar[row["pillar"]].append(row)
|
||||
|
||||
pillars_out = []
|
||||
all_scores = []
|
||||
for pname in pillar_order:
|
||||
qs = by_pillar.get(pname, [])
|
||||
if not qs:
|
||||
continue
|
||||
scores = [q["score"] for q in qs]
|
||||
avg = round(sum(scores) / len(scores), 2)
|
||||
all_scores.extend(scores)
|
||||
level = score_to_level(avg, scoring)
|
||||
pillars_out.append({
|
||||
"name": pname,
|
||||
"avg": avg,
|
||||
"level": level,
|
||||
"label": level_label(level, scoring),
|
||||
"questions": [
|
||||
{
|
||||
"q_num": q["q_num"],
|
||||
"topic": q["topic"],
|
||||
"score": int(q["score"]) if q["score"] == int(q["score"]) else q["score"],
|
||||
"level": score_to_level(q["score"], scoring),
|
||||
"label": q["label"] or level_label(score_to_level(q["score"], scoring), scoring),
|
||||
"rationale": q["rationale"],
|
||||
"gaps": q["gaps"],
|
||||
"refs": q["refs"],
|
||||
}
|
||||
for q in qs
|
||||
],
|
||||
})
|
||||
|
||||
if not all_scores:
|
||||
print(f" WARNING: no valid scores found for {source['id']}")
|
||||
return None
|
||||
|
||||
overall = round(sum(all_scores) / len(all_scores), 2)
|
||||
overall_level = score_to_level(overall, scoring)
|
||||
return {
|
||||
"id": source["id"],
|
||||
"label": source["label"],
|
||||
"short": source["short"],
|
||||
"group": source["group"],
|
||||
"overall_score": overall,
|
||||
"overall_level": overall_level,
|
||||
"overall_label": level_label(overall_level, scoring),
|
||||
"pillars": pillars_out,
|
||||
}
|
||||
|
||||
# ── Client handlers ────────────────────────────────────────────────────────────
|
||||
def build_adeo(config, entity_filter=None):
|
||||
scoring = config["scoring"]
|
||||
pillar_order = config["pillars"]
|
||||
sources = ADEO_SOURCES
|
||||
if entity_filter:
|
||||
sources = [s for s in ADEO_SOURCES if s["id"] == entity_filter]
|
||||
if not sources:
|
||||
print(f"ERROR: entity '{entity_filter}' not found in ADEO sources")
|
||||
sys.exit(1)
|
||||
entities = []
|
||||
for source in sources:
|
||||
print(f" Reading {source['short']}…")
|
||||
entity = parse_entity(source, scoring, pillar_order)
|
||||
if entity:
|
||||
entities.append(entity)
|
||||
print(f" → {entity['overall_score']} ({entity['overall_label']})")
|
||||
entities.sort(key=lambda e: e["overall_score"], reverse=True)
|
||||
return entities
|
||||
|
||||
CLIENT_BUILDERS = {
|
||||
"adeo": build_adeo,
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("client_id", nargs="?", default="adeo")
|
||||
parser.add_argument("--entity", default=None, help="Sync only this entity ID")
|
||||
args = parser.parse_args()
|
||||
client_id = args.client_id
|
||||
entity_filter = args.entity
|
||||
|
||||
config_path = SCRIPT_DIR / "clients" / client_id / "config.json"
|
||||
if not config_path.exists():
|
||||
print(f"ERROR: config not found at {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if client_id not in CLIENT_BUILDERS:
|
||||
print(f"ERROR: no builder registered for client '{client_id}'")
|
||||
print(f" Available: {list(CLIENT_BUILDERS.keys())}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Building data for client: {config['name']}" + (f" [entity: {entity_filter}]" if entity_filter else ""))
|
||||
entities = CLIENT_BUILDERS[client_id](config, entity_filter=entity_filter)
|
||||
|
||||
out_path = SCRIPT_DIR / "clients" / client_id / "data.json"
|
||||
|
||||
if entity_filter:
|
||||
# Merge updated entity into existing data, preserve all others
|
||||
existing_entities = []
|
||||
if out_path.exists():
|
||||
with open(out_path, encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
existing_entities = existing.get("entities", [])
|
||||
updated = {e["id"]: e for e in entities}
|
||||
merged = [updated.get(e["id"], e) for e in existing_entities]
|
||||
# Add if not already present
|
||||
existing_ids = {e["id"] for e in existing_entities}
|
||||
for e in entities:
|
||||
if e["id"] not in existing_ids:
|
||||
merged.append(e)
|
||||
merged.sort(key=lambda e: e["overall_score"], reverse=True)
|
||||
output = {
|
||||
"generated": date.today().isoformat(),
|
||||
"client_id": client_id,
|
||||
"entities": merged,
|
||||
}
|
||||
else:
|
||||
output = {
|
||||
"generated": date.today().isoformat(),
|
||||
"client_id": client_id,
|
||||
"entities": entities,
|
||||
}
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\n✓ Written {len(output['entities'])} entities → {out_path}")
|
||||
scores_str = " · ".join(f"{e['short']} {e['overall_score']}" for e in output["entities"])
|
||||
print(f" Scores: {scores_str}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1004
generate_brazil_summary.py
Normal file
1004
generate_brazil_summary.py
Normal file
File diff suppressed because it is too large
Load diff
778
generate_summary.py
Normal file
778
generate_summary.py
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
#!/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()
|
||||
218
import_file.py
Normal file
218
import_file.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_file.py <client_id> <entity_id> <filepath>
|
||||
|
||||
Parses a single CSV or XLSX maturity file and outputs JSON:
|
||||
{ overall_score, overall_level, overall_label, pillars: [...] }
|
||||
|
||||
Called by the server's POST /api/clients/:id/import/file endpoint.
|
||||
Supports Schema A (France/Poland), Schema B (Italy/Portugal/M-markets),
|
||||
and XLSX_A (Spain V4).
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
# ── Score helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_score(s):
|
||||
if s is None:
|
||||
return None
|
||||
m = re.search(r"\d+(?:\.\d+)?", str(s))
|
||||
return float(m.group()) if m else None
|
||||
|
||||
|
||||
def score_to_level(score, scoring):
|
||||
if score is None:
|
||||
return None
|
||||
mn, mx = scoring["min"], scoring["max"]
|
||||
num_levels = len(scoring["labels"])
|
||||
bucket = (mx - mn) / num_levels
|
||||
for i in range(1, num_levels + 1):
|
||||
if score <= mn + bucket * i:
|
||||
return i
|
||||
return num_levels
|
||||
|
||||
|
||||
def level_label(level, scoring):
|
||||
return scoring["labels"].get(str(level), "") if level is not None else ""
|
||||
|
||||
|
||||
# ── Schema detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def detect_schema(headers):
|
||||
h = [x.lower().strip() for x in headers]
|
||||
if "question_number" in h:
|
||||
return "B"
|
||||
if "q#" in h:
|
||||
# Could be Schema A (CSV) or XLSX_A — disambiguate by looking for 'question topic'
|
||||
if "question topic" in h:
|
||||
return "XLSX_A"
|
||||
return "A"
|
||||
# Fallback: try B
|
||||
return "B"
|
||||
|
||||
|
||||
# ── Row normalisation ─────────────────────────────────────────────────────────
|
||||
|
||||
def normalise_row(raw, schema):
|
||||
if schema == "A":
|
||||
return {
|
||||
"q_num": str(raw.get("Q#", raw.get("Q #", ""))).strip(),
|
||||
"topic": str(raw.get("Question", "")).strip(),
|
||||
"pillar": str(raw.get("Pillar", "")).strip().upper(),
|
||||
"score": parse_score(raw.get("Score", "")),
|
||||
"label": str(raw.get("Score_Label", "")).strip(),
|
||||
"rationale": str(raw.get("Scoring_Rationale", "")).strip(),
|
||||
"gaps": str(raw.get("Gaps_Identified", "")).strip(),
|
||||
"refs": str(raw.get("Evidence_References", "")).strip(),
|
||||
}
|
||||
if schema == "XLSX_A":
|
||||
return {
|
||||
"q_num": str(raw.get("Q#", "")).strip(),
|
||||
"topic": str(raw.get("Question Topic", "")).strip(),
|
||||
"pillar": str(raw.get("Pillar", "")).strip().upper(),
|
||||
"score": parse_score(raw.get("Score", "")),
|
||||
"label": str(raw.get("Level", "")).strip(),
|
||||
"rationale": str(raw.get("Rationale", "")).strip(),
|
||||
"gaps": str(raw.get("Gaps", "")).strip(),
|
||||
"refs": str(raw.get("References Used", "")).strip(),
|
||||
}
|
||||
# Schema B (default)
|
||||
return {
|
||||
"q_num": str(raw.get("Question_Number", "")).strip(),
|
||||
"topic": str(raw.get("Question_Topic", "")).strip(),
|
||||
"pillar": str(raw.get("Pillar", "")).strip().upper(),
|
||||
"score": parse_score(raw.get("Score_Numeric", raw.get("Score", ""))),
|
||||
"label": str(raw.get("Score_Label", "")).strip(),
|
||||
"rationale": str(raw.get("Rationale", "")).strip(),
|
||||
"gaps": str(raw.get("Gaps", "")).strip(),
|
||||
"refs": str(raw.get("References_Used", raw.get("References", ""))).strip(),
|
||||
}
|
||||
|
||||
|
||||
# ── File readers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def read_csv(filepath):
|
||||
with open(filepath, encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
headers = reader.fieldnames or []
|
||||
rows = list(reader)
|
||||
return headers, rows
|
||||
|
||||
|
||||
def read_xlsx(filepath):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
raise RuntimeError("openpyxl required for XLSX files. Run: pip install openpyxl")
|
||||
wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
headers = None
|
||||
rows = []
|
||||
for excel_row in ws.iter_rows(values_only=True):
|
||||
if headers is None:
|
||||
headers = [str(c).strip() if c is not None else "" for c in excel_row]
|
||||
continue
|
||||
rows.append(dict(zip(headers, [str(v).strip() if v is not None else "" for v in excel_row])))
|
||||
wb.close()
|
||||
return headers or [], rows
|
||||
|
||||
|
||||
def read_file(filepath):
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if ext in (".xlsx", ".xls"):
|
||||
return read_xlsx(filepath)
|
||||
return read_csv(filepath)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_result(client_id, entity_id, filepath):
|
||||
cfg_path = ROOT / "clients" / client_id / "config.json"
|
||||
with open(cfg_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
scoring = config["scoring"]
|
||||
pillar_order = config["pillars"]
|
||||
|
||||
headers, raw_rows = read_file(filepath)
|
||||
schema = detect_schema(headers)
|
||||
|
||||
# Normalise and filter invalid scores
|
||||
questions = []
|
||||
for raw in raw_rows:
|
||||
row = normalise_row(raw, schema)
|
||||
if row["score"] is None:
|
||||
continue
|
||||
score = int(round(row["score"]))
|
||||
level = score_to_level(score, scoring)
|
||||
questions.append({
|
||||
"q_num": row["q_num"],
|
||||
"topic": row["topic"],
|
||||
"pillar": row["pillar"],
|
||||
"score": score,
|
||||
"level": level,
|
||||
"label": level_label(level, scoring),
|
||||
"rationale": row["rationale"],
|
||||
"gaps": row["gaps"],
|
||||
"refs": row["refs"],
|
||||
})
|
||||
|
||||
# Group by pillar in config order
|
||||
by_pillar = {}
|
||||
for q in questions:
|
||||
by_pillar.setdefault(q["pillar"], []).append(q)
|
||||
|
||||
pillars_out = []
|
||||
all_scores = []
|
||||
for pname in pillar_order:
|
||||
qs = by_pillar.get(pname, [])
|
||||
if not qs:
|
||||
continue
|
||||
scores = [q["score"] for q in qs]
|
||||
avg = round(sum(scores) / len(scores), 2)
|
||||
level = score_to_level(avg, scoring)
|
||||
all_scores.extend(scores)
|
||||
pillars_out.append({
|
||||
"name": pname,
|
||||
"avg": avg,
|
||||
"level": level,
|
||||
"label": level_label(level, scoring),
|
||||
"questions": [{k: v for k, v in q.items() if k != "pillar"} for q in qs],
|
||||
})
|
||||
|
||||
if not all_scores:
|
||||
raise ValueError("No valid scores found in the uploaded file. Check the file format and column names.")
|
||||
|
||||
overall = round(sum(all_scores) / len(all_scores), 2)
|
||||
overall_level = score_to_level(overall, scoring)
|
||||
return {
|
||||
"overall_score": overall,
|
||||
"overall_level": overall_level,
|
||||
"overall_label": level_label(overall_level, scoring),
|
||||
"pillars": pillars_out,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 4:
|
||||
print(json.dumps({"error": "Usage: import_file.py <client_id> <entity_id> <filepath>"}))
|
||||
sys.exit(1)
|
||||
|
||||
client_id = sys.argv[1]
|
||||
entity_id = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
|
||||
try:
|
||||
result = build_result(client_id, entity_id, filepath)
|
||||
print(json.dumps(result))
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
472
index.html
Normal file
472
index.html
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maturity Tool</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
/* ── CSS Variables — Dark (default) ── */
|
||||
:root {
|
||||
--bg: #111111;
|
||||
--bg-card: #1c1c1c;
|
||||
--bg-modal: #1a1a1a;
|
||||
--bg-input: #1c1c1c;
|
||||
--bg-inset: #111111;
|
||||
--border: #262626;
|
||||
--border-sub: #1e1e1e;
|
||||
--text: #f3f4f6;
|
||||
--text-sub: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--text-faint: #4b5563;
|
||||
--scrolltrack: #1a1a1a;
|
||||
--scrollthumb: #3a3a3a;
|
||||
--header-bg: #111111;
|
||||
--header-bdr: #1e1e1e;
|
||||
--shadow: none;
|
||||
--accent: #78BE20;
|
||||
}
|
||||
|
||||
/* ── CSS Variables — Light ── */
|
||||
body.light {
|
||||
--bg: #f4f4f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-modal: #ffffff;
|
||||
--bg-input: #f9fafb;
|
||||
--bg-inset: #f4f4f5;
|
||||
--border: #e4e4e7;
|
||||
--border-sub: #f0f0f0;
|
||||
--text: #111111;
|
||||
--text-sub: #52525b;
|
||||
--text-muted: #71717a;
|
||||
--text-faint: #a1a1aa;
|
||||
--scrolltrack: #e4e4e7;
|
||||
--scrollthumb: #d4d4d8;
|
||||
--header-bg: #ffffff;
|
||||
--header-bdr: #e4e4e7;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--scrolltrack); }
|
||||
::-webkit-scrollbar-thumb { background: var(--scrollthumb); border-radius: 3px; }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tab-btn {
|
||||
padding: 10px 20px; font-size: 14px; font-weight: 500;
|
||||
color: var(--text-muted); border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap; background: none;
|
||||
border-top: none; border-left: none; border-right: none;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ── Panel ── */
|
||||
.panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ── Section header ── */
|
||||
.section-header {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--accent); margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.badge {
|
||||
display: inline-block; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Score level badge colours ── */
|
||||
.score-1 { background: #C62828; color: #fff; }
|
||||
.score-2 { background: #E65100; color: #fff; }
|
||||
.score-3 { background: #2E7D32; color: #fff; }
|
||||
.score-4 { background: #1B5E20; color: #fff; }
|
||||
|
||||
.score-bg-1 { background: rgba(198,40,40,0.15); border: 1px solid rgba(198,40,40,0.35); }
|
||||
.score-bg-2 { background: rgba(230,81,0,0.15); border: 1px solid rgba(230,81,0,0.35); }
|
||||
.score-bg-3 { background: rgba(46,125,50,0.15); border: 1px solid rgba(46,125,50,0.35); }
|
||||
.score-bg-4 { background: rgba(27,94,32,0.15); border: 1px solid rgba(27,94,32,0.35); }
|
||||
body.light .score-bg-1 { background: #FFEBEE; border-color: #C62828; }
|
||||
body.light .score-bg-2 { background: #FFF3E0; border-color: #E65100; }
|
||||
body.light .score-bg-3 { background: #E8F5E9; border-color: #2E7D32; }
|
||||
body.light .score-bg-4 { background: #F1F8E9; border-color: #1B5E20; }
|
||||
|
||||
/* ── Entity cards ── */
|
||||
.entity-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 18px; cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.entity-card:hover {
|
||||
border-color: var(--accent); transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
/* ── Client cards (home screen) ── */
|
||||
.client-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 28px; cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.client-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.2); }
|
||||
|
||||
/* ── Pillar mini-bars ── */
|
||||
.pillar-bar-track { height: 4px; border-radius: 2px; background: var(--border); margin-top: 3px; }
|
||||
.pillar-bar-fill { height: 4px; border-radius: 2px; background: var(--accent); transition: width 0.6s ease; }
|
||||
|
||||
/* ── Pillar accordion (entity detail) ── */
|
||||
.pillar-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||||
.pillar-card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; cursor: pointer; transition: background 0.15s; }
|
||||
.pillar-card-header:hover { background: var(--bg-inset); }
|
||||
.pillar-card-body { display: none; border-top: 1px solid var(--border); }
|
||||
.pillar-card.open .pillar-card-body { display: block; }
|
||||
.pillar-chevron { transition: transform 0.2s; color: var(--text-muted); flex-shrink: 0; }
|
||||
.pillar-card.open .pillar-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* ── Question rows ── */
|
||||
.question-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 10px 16px; border-bottom: 1px solid var(--border-sub);
|
||||
cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.question-row:last-child { border-bottom: none; }
|
||||
.question-row:hover { background: var(--bg-inset); }
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: rgba(0,0,0,0.65);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px; overflow-y: auto;
|
||||
}
|
||||
.modal-overlay.hidden { display: none; }
|
||||
.modal-box {
|
||||
background: var(--bg-modal); border: 1px solid var(--border);
|
||||
border-radius: 12px; width: 100%; max-width: 740px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
padding: 28px; position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.spec-detail-row {
|
||||
display: grid; grid-template-columns: 150px 1fr;
|
||||
gap: 8px 16px; padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-sub);
|
||||
}
|
||||
.spec-detail-row:last-child { border-bottom: none; }
|
||||
.spec-detail-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); padding-top: 2px; }
|
||||
.spec-detail-value { font-size: 13px; color: var(--text); line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
|
||||
.spec-detail-value.empty { color: var(--text-faint); font-style: italic; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
background: var(--accent); color: #111; font-weight: 700;
|
||||
padding: 9px 20px; border-radius: 6px; font-size: 13px;
|
||||
border: none; cursor: pointer; transition: opacity 0.2s;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.88; }
|
||||
.btn-ghost {
|
||||
background: transparent; color: var(--text-sub); font-weight: 500;
|
||||
padding: 8px 16px; border-radius: 6px; font-size: 13px;
|
||||
border: 1px solid var(--border); cursor: pointer; transition: border-color 0.2s, color 0.2s;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--text); }
|
||||
|
||||
/* ── Theme toggle ── */
|
||||
.theme-toggle {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-muted); display: flex; align-items: center;
|
||||
justify-content: center; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── Compare table ── */
|
||||
.compare-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.compare-table th {
|
||||
padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--accent); border-bottom: 2px solid var(--accent);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.compare-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-sub); vertical-align: middle; }
|
||||
.compare-table tr:last-child td { border-bottom: none; }
|
||||
.compare-table .row-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); width: 190px; }
|
||||
.compare-table td.diff-hi { background: rgba(46,125,50,0.1); font-weight: 600; color: var(--text); }
|
||||
.compare-table td.diff-lo { background: rgba(198,40,40,0.1); font-weight: 600; color: var(--text); }
|
||||
|
||||
/* ── Summary stat box ── */
|
||||
.stat-box { text-align: center; padding: 12px 16px; }
|
||||
.stat-num { font-size: 26px; font-weight: 800; color: var(--text); line-height: 1; }
|
||||
.stat-lbl { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 100;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px 18px;
|
||||
font-size: 13px; color: var(--text);
|
||||
transform: translateY(80px); opacity: 0;
|
||||
transition: all 0.3s ease; pointer-events: none;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.success { border-left: 3px solid #34d399; }
|
||||
#toast.error { border-left: 3px solid #f87171; }
|
||||
|
||||
/* ── Cards grid ── */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Back button row ── */
|
||||
.back-row { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||
|
||||
/* ── Fade-up animation ── */
|
||||
@keyframes fadeUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
||||
.fade-up { animation: fadeUp 0.25s ease both; }
|
||||
|
||||
/* ── Import modal ── */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border); border-radius: 8px;
|
||||
padding: 28px 16px; text-align: center; cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--accent); background: rgba(120,190,32,0.04);
|
||||
}
|
||||
.drop-zone.has-file { border-color: var(--accent); }
|
||||
.sync-log {
|
||||
display: none; margin-top: 14px; padding: 12px 14px;
|
||||
background: var(--bg-inset); border: 1px solid var(--border);
|
||||
border-radius: 8px; font-family: ui-monospace, monospace;
|
||||
font-size: 12px; color: var(--text-sub); white-space: pre-wrap;
|
||||
max-height: 220px; overflow-y: auto; line-height: 1.5;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--text-muted);
|
||||
display: block; margin-bottom: 6px;
|
||||
}
|
||||
select.field-select {
|
||||
width: 100%; padding: 8px 10px; background: var(--bg-input);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); font-size: 13px; outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
select.field-select:focus { border-color: var(--accent); }
|
||||
/* ── New client card ── */
|
||||
.new-client-card {
|
||||
background: transparent; border: 2px dashed var(--border);
|
||||
border-radius: 12px; padding: 28px; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 8px; transition: border-color 0.2s, background 0.2s; min-height: 180px;
|
||||
}
|
||||
.new-client-card:hover { border-color: var(--accent); background: var(--bg-inset); }
|
||||
/* ── Wizard inputs ── */
|
||||
.wizard-input {
|
||||
width: 100%; padding: 9px 11px; background: var(--bg-input);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); font-size: 13px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--accent); }
|
||||
.wizard-input::placeholder { color: var(--text-faint); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══ HEADER ══════════════════════════════════════════════════════════════ -->
|
||||
<header style="background:var(--header-bg);border-bottom:1px solid var(--header-bdr);position:sticky;top:0;z-index:40;padding:16px 24px;box-shadow:var(--shadow);">
|
||||
<div style="max-width:1280px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<!-- Back to home (shown when inside a client) -->
|
||||
<button id="homeBtn" onclick="goHome()" style="display:none;background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;border-radius:6px;transition:color 0.2s;" title="All clients">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 style="font-size:17px;font-weight:800;color:var(--text);margin:0;line-height:1.2;" id="headerTitle">Maturity Tool</h1>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin:2px 0 0;" id="headerSub">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle" title="Toggle theme">
|
||||
<svg id="iconDark" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
|
||||
<svg id="iconLight" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ══ TAB BAR (shown inside a client) ══════════════════════════════════════ -->
|
||||
<div id="tabBar" style="display:none;background:var(--header-bg);border-bottom:1px solid var(--header-bdr);position:sticky;top:57px;z-index:30;">
|
||||
<div style="max-width:1280px;margin:0 auto;padding:0 24px;display:flex;">
|
||||
<button class="tab-btn active" id="tab-entities" onclick="showTab('entities')">Markets</button>
|
||||
<button class="tab-btn" id="tab-compare" onclick="showTab('compare')">Compare</button>
|
||||
<button class="tab-btn" id="tab-update" onclick="showTab('update')">Update Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ MAIN CONTENT ══════════════════════════════════════════════════════════ -->
|
||||
<div style="max-width:1280px;margin:0 auto;padding:24px;">
|
||||
|
||||
<!-- ── Home screen: client selector ── -->
|
||||
<div id="homeScreen">
|
||||
<div id="clientCards" class="cards-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Client view ── -->
|
||||
<div id="clientView" style="display:none;">
|
||||
|
||||
<!-- Tab: Entities (Markets) -->
|
||||
<div id="tab-entities-content">
|
||||
<!-- Summary bar -->
|
||||
<div class="panel fade-up" id="summaryBar" style="margin-bottom:16px;"></div>
|
||||
<!-- Export row (all entities) -->
|
||||
<div id="exportRow" style="display:none;margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-right:4px;">Export all</span>
|
||||
<button class="btn-ghost" onclick="exportCsv()" title="Download flat CSV of all entities">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
CSV
|
||||
</button>
|
||||
<button class="btn-ghost" onclick="exportXlsx(null)" title="Download formatted Excel workbook">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
XLSX
|
||||
</button>
|
||||
</div>
|
||||
<!-- Entity cards grid -->
|
||||
<div id="entityGrid" class="cards-grid"></div>
|
||||
<!-- Entity detail panel (replaces grid) -->
|
||||
<div id="detailPanel" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Compare -->
|
||||
<div id="tab-compare-content" style="display:none;">
|
||||
<div class="panel fade-up" id="compareSelector" style="margin-bottom:16px;"></div>
|
||||
<div class="panel fade-up" id="compareResult" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Update Data -->
|
||||
<div id="tab-update-content" style="display:none;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;">
|
||||
|
||||
<!-- Sync from Box -->
|
||||
<div class="panel fade-up">
|
||||
<p class="section-header">Sync from Box</p>
|
||||
<p style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.65;">
|
||||
Re-run the data converter against the current source files in Box.
|
||||
</p>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="field-label" for="syncEntitySel">Scope</label>
|
||||
<select id="syncEntitySel" class="field-select">
|
||||
<option value="">All entities</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" id="syncRunBtn" onclick="runBoxSync()">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
||||
Sync from Box
|
||||
</button>
|
||||
<div class="sync-log" id="syncLog"></div>
|
||||
</div>
|
||||
|
||||
<!-- Upload File -->
|
||||
<div class="panel fade-up" style="animation-delay:40ms;">
|
||||
<p class="section-header">Upload File</p>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="field-label" for="importEntitySel">Entity to update</label>
|
||||
<select id="importEntitySel" class="field-select">
|
||||
<option value="">-- Select entity --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom:18px;">
|
||||
<label class="field-label">File (CSV or XLSX)</label>
|
||||
<div class="drop-zone" id="dropZone"
|
||||
onclick="document.getElementById('importFileInput').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="handleFileDrop(event)">
|
||||
<input type="file" id="importFileInput" accept=".csv,.xlsx,.xls" style="display:none;" onchange="onFileSelected(this.files[0])">
|
||||
<svg width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color:var(--text-muted);margin-bottom:10px;display:block;margin-left:auto;margin-right:auto;"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<p id="dropZoneText" style="font-size:13px;color:var(--text-muted);margin:0;line-height:1.4;">Drop a CSV or XLSX here, or click to browse</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="btn-primary" id="importRunBtn" onclick="runFileImport()">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Import
|
||||
</button>
|
||||
<span style="font-size:11px;color:var(--text-muted);">Updates scores only — preserves entity name & metadata</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══ WIZARD MODAL ═════════════════════════════════════════════════════════ -->
|
||||
<div id="wizardModal" class="modal-overlay hidden" onclick="handleWizardOverlayClick(event)">
|
||||
<div class="modal-box" style="max-width:540px;" onclick="event.stopPropagation()">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||
<div>
|
||||
<h2 style="font-size:17px;font-weight:700;color:var(--text);margin:0;">New Client Setup</h2>
|
||||
<p id="wizardStepLabel" style="font-size:12px;color:var(--text-muted);margin:4px 0 0;"></p>
|
||||
</div>
|
||||
<button onclick="closeWizard()" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;flex-shrink:0;" title="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="wizardDots" style="display:flex;gap:4px;align-items:center;margin-bottom:24px;"></div>
|
||||
<div id="wizardBody"></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;padding-top:16px;border-top:1px solid var(--border);">
|
||||
<button id="wizardBackBtn" class="btn-ghost" onclick="wizardBack()">← Back</button>
|
||||
<button id="wizardNextBtn" class="btn-primary" onclick="wizardNext()">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ QUESTION MODAL ════════════════════════════════════════════════════════ -->
|
||||
<div id="questionModal" class="modal-overlay hidden" onclick="handleModalOverlayClick(event)">
|
||||
<div class="modal-box" onclick="event.stopPropagation()">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:20px;">
|
||||
<div style="padding-right:32px;flex:1;">
|
||||
<div id="qModalBadges" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;"></div>
|
||||
<h2 id="qModalTitle" style="font-size:17px;font-weight:700;color:var(--text);margin:0;line-height:1.4;"></h2>
|
||||
<p id="qModalMeta" style="font-size:12px;color:var(--text-muted);margin:5px 0 0;"></p>
|
||||
</div>
|
||||
<button onclick="closeModal()" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;flex-shrink:0;" title="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="qModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ TOAST ═════════════════════════════════════════════════════════════════ -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
943
package-lock.json
generated
Normal file
943
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,943 @@
|
|||
{
|
||||
"name": "maturity-tool",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maturity-tool",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"multer": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "maturity-tool",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"multer": "^2.0.0"
|
||||
}
|
||||
}
|
||||
390
pdf_generator.py
Normal file
390
pdf_generator.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
#!/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])
|
||||
240
server/index.js
Normal file
240
server/index.js
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
'use strict';
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const multer = require('multer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3102;
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const CLIENT_DIR = path.join(ROOT, 'clients');
|
||||
const TMP_DIR = path.join(ROOT, 'tmp_uploads');
|
||||
|
||||
// Ensure tmp upload dir exists
|
||||
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
|
||||
// Multer — disk storage preserving original extension
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: TMP_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.csv';
|
||||
cb(null, `upload_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 30 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ok = ['.csv', '.xlsx', '.xls'].includes(path.extname(file.originalname).toLowerCase());
|
||||
cb(ok ? null : new Error('Only CSV and XLSX files are supported'), ok);
|
||||
},
|
||||
});
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function listClients() {
|
||||
if (!fs.existsSync(CLIENT_DIR)) return [];
|
||||
return fs.readdirSync(CLIENT_DIR).filter(id => {
|
||||
const cfg = path.join(CLIENT_DIR, id, 'config.json');
|
||||
const dat = path.join(CLIENT_DIR, id, 'data.json');
|
||||
return fs.existsSync(cfg) && fs.existsSync(dat);
|
||||
});
|
||||
}
|
||||
|
||||
// Health
|
||||
app.get('/api/health', (_req, res) => {
|
||||
const clients = listClients();
|
||||
res.json({ status: 'ok', clients });
|
||||
});
|
||||
|
||||
// List clients
|
||||
app.get('/api/clients', (_req, res) => {
|
||||
const clients = listClients().map(id => {
|
||||
const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json'));
|
||||
const dat = readJson(path.join(CLIENT_DIR, id, 'data.json'));
|
||||
return {
|
||||
id: cfg.id,
|
||||
name: cfg.name,
|
||||
description: cfg.description,
|
||||
accent_color: cfg.accent_color,
|
||||
entity_label: cfg.entity_label,
|
||||
logo: cfg.logo || null,
|
||||
entity_count: dat.entities ? dat.entities.length : 0,
|
||||
generated: dat.generated,
|
||||
};
|
||||
});
|
||||
res.json(clients);
|
||||
});
|
||||
|
||||
// Client config
|
||||
app.get('/api/clients/:id/config', (req, res) => {
|
||||
const cfgPath = path.join(CLIENT_DIR, req.params.id, 'config.json');
|
||||
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
|
||||
res.json(readJson(cfgPath));
|
||||
});
|
||||
|
||||
// Client data
|
||||
app.get('/api/clients/:id/data', (req, res) => {
|
||||
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
|
||||
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Data not found — run: python3 convert_data.py ' + req.params.id });
|
||||
res.json(readJson(datPath));
|
||||
});
|
||||
|
||||
// Save updated client data (score edits)
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.post('/api/clients/:id/data', (req, res) => {
|
||||
const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json');
|
||||
if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Client not found' });
|
||||
try {
|
||||
const payload = req.body;
|
||||
if (!payload || !Array.isArray(payload.entities)) {
|
||||
return res.status(400).json({ error: 'Invalid payload — expected { entities: [...] }' });
|
||||
}
|
||||
fs.writeFileSync(datPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to save: ' + e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new client (writes config.json + empty data.json)
|
||||
app.post('/api/clients', (req, res) => {
|
||||
const config = req.body;
|
||||
if (!config || !config.name) return res.status(400).json({ error: 'name is required' });
|
||||
const id = String(config.id || config.name)
|
||||
.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim()
|
||||
.replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!id) return res.status(400).json({ error: 'Could not derive a valid client ID' });
|
||||
const clientDir = path.join(CLIENT_DIR, id);
|
||||
if (fs.existsSync(clientDir)) return res.status(409).json({ error: `Client "${id}" already exists` });
|
||||
fs.mkdirSync(clientDir, { recursive: true });
|
||||
const finalConfig = { ...config, id };
|
||||
fs.writeFileSync(path.join(clientDir, 'config.json'), JSON.stringify(finalConfig, null, 2), 'utf8');
|
||||
const emptyData = { generated: new Date().toISOString().slice(0, 10), client_id: id, entities: [] };
|
||||
fs.writeFileSync(path.join(clientDir, 'data.json'), JSON.stringify(emptyData, null, 2), 'utf8');
|
||||
res.json({ ok: true, id });
|
||||
});
|
||||
|
||||
// Deliverables manifest (deliverables.json — optional per client)
|
||||
app.get('/api/clients/:id/deliverables', (req, res) => {
|
||||
const delPath = path.join(CLIENT_DIR, req.params.id, 'deliverables.json');
|
||||
if (!fs.existsSync(delPath)) return res.json({});
|
||||
res.json(readJson(delPath));
|
||||
});
|
||||
|
||||
// Stream a single deliverable file (pdf or xlsx)
|
||||
app.get('/api/clients/:id/deliverables/:entityId/:type', (req, res) => {
|
||||
const { id, entityId, type } = req.params;
|
||||
if (!['pdf', 'xlsx'].includes(type)) return res.status(400).json({ error: 'type must be pdf or xlsx' });
|
||||
const delPath = path.join(CLIENT_DIR, id, 'deliverables.json');
|
||||
if (!fs.existsSync(delPath)) return res.status(404).json({ error: 'No deliverables configured for this client' });
|
||||
const map = readJson(delPath);
|
||||
const entry = map[entityId];
|
||||
if (!entry || !entry[type]) return res.status(404).json({ error: `No ${type} deliverable configured for ${entityId}` });
|
||||
const filePath = entry[type];
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found on disk: ' + path.basename(filePath) });
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = ext === '.pdf' ? 'application/pdf'
|
||||
: ext === '.xlsx' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
: 'text/csv';
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// Box sync — re-runs convert_data.py for this client (all entities or a single one)
|
||||
app.post('/api/clients/:id/sync', (req, res) => {
|
||||
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
|
||||
if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' });
|
||||
const script = path.join(ROOT, 'convert_data.py');
|
||||
const entityId = (req.body && req.body.entity_id) ? String(req.body.entity_id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
|
||||
const args = entityId ? [script, id, '--entity', entityId] : [script, id];
|
||||
execFile('python3', args, { cwd: ROOT, timeout: 120000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Sync failed:', stderr);
|
||||
return res.status(500).json({ error: 'Sync failed', detail: stderr, log: stdout });
|
||||
}
|
||||
res.json({ ok: true, log: stdout });
|
||||
});
|
||||
});
|
||||
|
||||
// File import — parse an uploaded CSV/XLSX and merge into an entity
|
||||
app.post('/api/clients/:id/import/file', upload.single('file'), (req, res) => {
|
||||
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const entityId = (req.body.entity_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const datPath = path.join(CLIENT_DIR, id, 'data.json');
|
||||
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
if (!entityId) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'entity_id required' }); }
|
||||
if (!fs.existsSync(datPath)) { fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Client not found' }); }
|
||||
|
||||
const script = path.join(ROOT, 'import_file.py');
|
||||
execFile('python3', [script, id, entityId, req.file.path], { cwd: ROOT, timeout: 30000 }, (err, stdout, stderr) => {
|
||||
try { fs.unlinkSync(req.file.path); } catch (_) {}
|
||||
if (err) {
|
||||
console.error('Import failed:', stderr);
|
||||
return res.status(500).json({ error: 'Import failed', detail: stderr });
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.trim());
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Could not parse import output', detail: stdout });
|
||||
}
|
||||
if (parsed.error) return res.status(400).json(parsed);
|
||||
|
||||
const data = readJson(datPath);
|
||||
const idx = data.entities.findIndex(e => e.id === entityId);
|
||||
if (idx < 0) return res.status(404).json({ error: `Entity '${entityId}' not found in data.json` });
|
||||
|
||||
// Preserve identity fields, replace scores
|
||||
const existing = data.entities[idx];
|
||||
data.entities[idx] = {
|
||||
id: existing.id,
|
||||
label: existing.label,
|
||||
short: existing.short,
|
||||
group: existing.group,
|
||||
overall_score: parsed.overall_score,
|
||||
overall_level: parsed.overall_level,
|
||||
overall_label: parsed.overall_label,
|
||||
pillars: parsed.pillars,
|
||||
};
|
||||
data.generated = new Date().toISOString().slice(0, 10);
|
||||
data.entities.sort((a, b) => b.overall_score - a.overall_score);
|
||||
fs.writeFileSync(datPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
res.json({ ok: true, entity: data.entities.find(e => e.id === entityId) });
|
||||
});
|
||||
});
|
||||
|
||||
// PDF export — generates via Python/ReportLab and streams back
|
||||
app.get('/api/clients/:id/export/pdf/:entityId', (req, res) => {
|
||||
const { id, entityId } = req.params;
|
||||
const datPath = path.join(CLIENT_DIR, id, 'data.json');
|
||||
const cfgPath = path.join(CLIENT_DIR, id, 'config.json');
|
||||
if (!fs.existsSync(datPath) || !fs.existsSync(cfgPath)) {
|
||||
return res.status(404).json({ error: 'Client not found' });
|
||||
}
|
||||
const script = path.join(ROOT, 'pdf_generator.py');
|
||||
execFile('python3', [script, id, entityId], { cwd: ROOT }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('PDF generation failed:', stderr);
|
||||
return res.status(500).json({ error: 'PDF generation failed', detail: stderr });
|
||||
}
|
||||
const pdfPath = path.join(CLIENT_DIR, id, 'exports', `${entityId}_Maturity_Report.pdf`);
|
||||
if (!fs.existsSync(pdfPath)) {
|
||||
return res.status(500).json({ error: 'PDF not found after generation' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${entityId}_Maturity_Report.pdf"`);
|
||||
fs.createReadStream(pdfPath).pipe(res);
|
||||
});
|
||||
});
|
||||
|
||||
// Static files
|
||||
app.use(express.static(ROOT, { index: 'index.html' }));
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(ROOT, 'index.html')));
|
||||
|
||||
app.listen(PORT, () => console.log(`Maturity Tool running on http://localhost:${PORT}`));
|
||||
Loading…
Add table
Reference in a new issue