diff --git a/tools/import_patch_history_xlsx.py b/tools/import_patch_history_xlsx.py new file mode 100644 index 0000000..dcde161 --- /dev/null +++ b/tools/import_patch_history_xlsx.py @@ -0,0 +1,182 @@ +"""Import historique patching depuis Planning Patching 2026.xlsx. + +Regle detection serveur patche : cellule hostname (col A) avec fond VERT. + +Pour chaque sheet hebdo S02..S52 : + - Extrait num semaine depuis le nom de sheet ('S16' -> 16) + - Col A: hostname + - Col N (index 13): date (datetime ou string) + - Col O (index 14) si present: heure + - Ajoute patch_history(server_id, date_patch, status='ok', notes='Semaine X') + +Usage: + python tools/import_patch_history_xlsx.py [--dry-run] [--sheets S16,S17] +""" +import os +import re +import argparse +from datetime import datetime, time, timedelta +from sqlalchemy import create_engine, text + +try: + import openpyxl +except ImportError: + print("[ERR] pip install openpyxl") + raise + +DATABASE_URL = os.getenv("DATABASE_URL_DEMO") or os.getenv("DATABASE_URL") \ + or "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_demo" + +# Codes couleur Excel "vert" (Office / standard) +GREEN_HEX_PREFIXES = ("00FF", "92D0", "C6EF", "A9D0", "B7E1", "55B5", "70AD", + "00B0", "00A0", "008", "00B5", "00C8") + + +def is_green(cell): + """Detecte si la cellule a un fond vert.""" + if cell.fill is None or cell.fill.fgColor is None: + return False + fc = cell.fill.fgColor + rgb = None + if fc.type == "rgb" and fc.rgb: + rgb = fc.rgb.upper() + elif fc.type == "theme": + # Themes Office : 9 = green1, 6 = accent2, etc. (approximatif) + if fc.theme in (9, 6, 4): + return True + if rgb and len(rgb) >= 6: + # AARRGGBB -> prendre le RRGGBB + rr = rgb[-6:-4]; gg = rgb[-4:-2]; bb = rgb[-2:] + try: + r, g, b = int(rr, 16), int(gg, 16), int(bb, 16) + # Vert dominant (G > R, G > B, G > 120) + return g > 120 and g > r + 30 and g > b + 30 + except ValueError: + return False + return False + + +def parse_week_num(sheet_name): + m = re.search(r"[Ss](\d{1,2})", sheet_name) + return int(m.group(1)) if m else None + + +def week_to_date(year, week): + """Retourne date du lundi de la semaine ISO.""" + d = datetime.strptime(f"{year}-W{week:02d}-1", "%Y-W%W-%w") + return d.date() + + +def parse_hour(val): + """Parse une cellule heure: '14:00', '14h', '14H30', datetime.time...""" + if val is None: + return None + if isinstance(val, time): + return val + if isinstance(val, datetime): + return val.time() + s = str(val).strip().lower().replace("h", ":") + m = re.match(r"(\d{1,2})(?::(\d{2}))?", s) + if not m: + return None + hh = int(m.group(1)) + mm = int(m.group(2) or 0) + if 0 <= hh < 24 and 0 <= mm < 60: + return time(hh, mm) + return None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("xlsx_path") + parser.add_argument("--year", type=int, default=2026) + parser.add_argument("--sheets", default="", help="Liste de sheets filtrees, ex: S15,S16") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + engine = create_engine(DATABASE_URL) + conn = engine.connect().execution_options(isolation_level="AUTOCOMMIT") + + wb = openpyxl.load_workbook(args.xlsx_path, data_only=True) + target_sheets = set(s.strip().upper() for s in args.sheets.split(",") if s.strip()) if args.sheets else None + + # Cache servers: hostname -> id + hosts = {} + for r in conn.execute(text("SELECT id, hostname FROM servers")).fetchall(): + hosts[r.hostname.lower()] = r.id + + stats = {"sheets": 0, "patched": 0, "inserted": 0, "no_server": 0, "skipped": 0} + + for sname in wb.sheetnames: + wk = parse_week_num(sname) + if wk is None or not (1 <= wk <= 53): + continue + if target_sheets and sname.upper() not in target_sheets: + continue + ws = wb[sname] + stats["sheets"] += 1 + default_date = week_to_date(args.year, wk) + + for row_idx in range(2, ws.max_row + 1): + hn_cell = ws.cell(row=row_idx, column=1) + hn = hn_cell.value + if not hn: + continue + hn = str(hn).strip().split(".")[0].lower() + if not any(c.isalpha() for c in hn): + continue + if not is_green(hn_cell): + continue + stats["patched"] += 1 + + sid = hosts.get(hn) + if not sid: + stats["no_server"] += 1 + continue + + # Date col N (14), heure col O (15) si present + date_val = ws.cell(row=row_idx, column=14).value + hour_val = ws.cell(row=row_idx, column=15).value + dt_base = None + if isinstance(date_val, datetime): + dt_base = date_val + elif date_val: + try: + dt_base = datetime.strptime(str(date_val).split()[0], "%d/%m/%Y") + except Exception: + dt_base = None + if not dt_base: + dt_base = datetime.combine(default_date, time(0, 0)) + hr = parse_hour(hour_val) + if hr: + dt_base = datetime.combine(dt_base.date(), hr) + + note = f"Semaine {wk:02d}" + if args.dry_run: + print(f" DRY [{sname}] {hn:25s} -> {dt_base.isoformat()} ({note})") + else: + try: + # Evite doublons exacts (server+date) + existing = conn.execute(text( + "SELECT id FROM patch_history WHERE server_id=:sid AND date_patch=:dt" + ), {"sid": sid, "dt": dt_base}).fetchone() + if existing: + stats["skipped"] += 1 + continue + conn.execute(text(""" + INSERT INTO patch_history (server_id, date_patch, status, notes) + VALUES (:sid, :dt, 'ok', :note) + """), {"sid": sid, "dt": dt_base, "note": note}) + stats["inserted"] += 1 + except Exception as e: + print(f" [ERR] {hn}: {str(e)[:120]}") + stats["skipped"] += 1 + + conn.close() + print(f"\n[DONE] Sheets: {stats['sheets']} | Patches detectes: {stats['patched']} " + f"| Inserts: {stats['inserted']} | Sans serveur: {stats['no_server']} " + f"| Skip: {stats['skipped']}") + + +if __name__ == "__main__": + main()