diff --git a/tools/align_from_ayoub.py b/tools/align_from_ayoub.py new file mode 100644 index 0000000..6b9224e --- /dev/null +++ b/tools/align_from_ayoub.py @@ -0,0 +1,168 @@ +"""Alignement servers depuis fichier Excel Ayoub (Planning Patching 2026_ayoub.xlsx). + +Lit le sheet 'Serveurs patchables 2026' et met a jour pour chaque hostname : + - domaine (col D : Domaine) + - environnement (col C : Environnement — override si vide) + - responsable_nom (col H : Responsable Domaine DTS) + - referent_nom (col J : Referent technique) + +Ajoute la colonne servers.domaine si absente (varchar(100)). + +Usage: + python tools/align_from_ayoub.py [--sheet "Serveurs patchables 2026"] [--dry-run] + +Requiert openpyxl: pip install openpyxl +""" +import os +import argparse +import re +from sqlalchemy import create_engine, text + +try: + import openpyxl +except ImportError: + print("[ERR] Installer openpyxl: pip install openpyxl") + raise + +DATABASE_URL = os.getenv("DATABASE_URL_DEMO") or os.getenv("DATABASE_URL") \ + or "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_demo" + +NBSP = "\u00a0" + + +def clean(v): + """Normalise une cellule Excel: strip + enleve nbsp + None si vide.""" + if v is None: + return None + s = str(v).replace(NBSP, " ").strip() + return s or None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("xlsx_path") + parser.add_argument("--sheet", default="Serveurs patchables 2026") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + engine = create_engine(DATABASE_URL) + print(f"[INFO] DB: {DATABASE_URL.split('@')[-1]}") + print(f"[INFO] Fichier: {args.xlsx_path}") + print(f"[INFO] Sheet: {args.sheet}") + + conn = engine.connect().execution_options(isolation_level="AUTOCOMMIT") + + # 1. Ajoute colonne domaine si absente (idempotent) + conn.execute(text("ALTER TABLE servers ADD COLUMN IF NOT EXISTS domaine varchar(100)")) + + # 2. Lecture Excel + wb = openpyxl.load_workbook(args.xlsx_path, data_only=True) + if args.sheet not in wb.sheetnames: + print(f"[ERR] Sheet '{args.sheet}' introuvable. Sheets: {wb.sheetnames}") + return + ws = wb[args.sheet] + + # Detection dynamique des colonnes via header (ligne 1) + header = [clean(c.value) for c in ws[1]] + print(f"[INFO] Header: {header[:12]}...") + + def col_idx(name_candidates): + for i, h in enumerate(header): + if h and any(cand.lower() in h.lower() for cand in name_candidates): + return i + return -1 + + idx_host = col_idx(["Asset Name", "Hostname", "Nom"]) + idx_env = col_idx(["Environnement"]) + idx_dom = col_idx(["Domaine"]) + idx_resp = col_idx(["Responsable Domaine"]) + idx_ref = col_idx(["Référent technique", "Referent technique"]) + + print(f"[INFO] Indices: host={idx_host} env={idx_env} dom={idx_dom} " + f"resp={idx_resp} ref={idx_ref}") + + if idx_host == -1: + print("[ERR] Colonne Asset Name/Hostname introuvable") + return + + stats = {"updated": 0, "unchanged": 0, "not_found": 0, "skipped": 0} + changes_detail = [] + + for row in ws.iter_rows(min_row=2, values_only=True): + hostname = clean(row[idx_host]) if idx_host < len(row) else None + if not hostname or not any(c.isalpha() for c in hostname): + stats["skipped"] += 1 + continue + + # Extract sans suffixe FQDN si present + hostname = hostname.split(".")[0].lower() + + fields = {} + if idx_dom >= 0 and idx_dom < len(row): + v = clean(row[idx_dom]) + if v: + fields["domaine"] = v[:100] + if idx_env >= 0 and idx_env < len(row): + v = clean(row[idx_env]) + if v: + fields["environnement"] = v[:50] + if idx_resp >= 0 and idx_resp < len(row): + v = clean(row[idx_resp]) + if v: + # Enleve les virgules multiples, normalise espaces + v = re.sub(r"\s+", " ", v) + fields["responsable_nom"] = v[:200] + if idx_ref >= 0 and idx_ref < len(row): + v = clean(row[idx_ref]) + if v: + v = re.sub(r"\s+", " ", v) + fields["referent_nom"] = v[:200] + + if not fields: + continue + + srv = conn.execute(text( + "SELECT id, domaine, environnement, responsable_nom, referent_nom " + "FROM servers WHERE hostname=:h" + ), {"h": hostname}).fetchone() + if not srv: + stats["not_found"] += 1 + continue + + # Calcule diff (pour ne pas ecraser si valeur identique) + diff = {} + for k, v in fields.items(): + current = getattr(srv, k, None) + if current != v: + diff[k] = (current, v) + + if not diff: + stats["unchanged"] += 1 + continue + + if args.dry_run: + changes_detail.append((hostname, diff)) + else: + set_clauses = ", ".join(f"{k}=:{k}" for k in diff) + params = {k: v[1] for k, v in diff.items()} + params["sid"] = srv.id + conn.execute(text(f"UPDATE servers SET {set_clauses} WHERE id=:sid"), params) + stats["updated"] += 1 + + if args.dry_run and changes_detail: + print(f"\n[DRY-RUN] Changements ({len(changes_detail)}) :") + for hostname, diff in changes_detail[:30]: + print(f" {hostname}:") + for k, (old, new) in diff.items(): + print(f" {k}: {old!r} -> {new!r}") + if len(changes_detail) > 30: + print(f" ... ({len(changes_detail)-30} autres)") + + print(f"\n[DONE] Maj: {stats['updated']} | Inchanges: {stats['unchanged']} " + f"| Hors base: {stats['not_found']} | Skip: {stats['skipped']}") + + conn.close() + + +if __name__ == "__main__": + main()