"""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()