Add align_from_ayoub: alignement servers depuis Excel Planning Patching

Lit la sheet 'Serveurs patchables 2026' et met a jour pour chaque hostname :
 - domaine (nouvelle colonne text iTop verbatim, ajoutee si absente)
 - environnement (override si non vide)
 - responsable_nom (Responsable Domaine DTS)
 - referent_nom (Referent technique)

Pas de jointure via domain_env_id: stockage plain-text aligne sur iTop.
Dry-run affiche les diff avant/apres.
This commit is contained in:
Pierre & Lumière 2026-04-14 19:17:25 +02:00
parent 9048a87397
commit c16a360cdd

168
tools/align_from_ayoub.py Normal file
View File

@ -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 <fichier.xlsx> [--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()