patchcenter/app/routers/planning_import.py
Admin MPCZ 5e5803afa2 feat(pct/cc): fallback match par nom dans contacts si servers.responsable_email/referent_email vides
Cas reel: les serveurs SANEF ont responsable_nom rempli (ex 'Laurent DELCOUR') mais
responsable_email vide. Les emails ne sont QUE dans la table contacts.

Solution: 4eme source = match par nom dans la table contacts.
- On collecte les noms texte presents en DB:
  * servers.responsable_nom / referent_nom (si email correspondant vide)
  * patch_planning_import_rows.responsable_domaine_dts / referent_technique
    (split sur '/', '-', 'et', 'ou', etc. pour les co-responsables)
- Pour chaque nom, match insensible casse: exact OR contains
- Recupere les emails associes dans contacts
2026-05-07 21:54:50 +02:00

1603 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Router import du planning de patching depuis Excel.
Fonctionnalités :
- Upload xlsx (multi-feuilles, 1 feuille = 1 semaine S02..S52)
- Liste des imports précédents
- Affichage du contenu d'un import : sélecteur de semaine + tableau des serveurs
- Endpoints JSON pour AJAX (sélection de semaine sans rechargement)
Le module pré-patching et le patching by-step seront branchés en étape 2/3.
"""
import io
import json
import re
import unicodedata
from datetime import date, datetime, time
from fastapi import APIRouter, Request, Depends, UploadFile, File, Form, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Détection auto Prévenance PCT à l'import : on scanne référent_technique / mode_operatoire /
# impacts pour les patterns "PCT" (mot entier, pour éviter PCE et autres faux positifs).
# Patterns acceptés : "PCT", "prévenance PCT", "prévenir PCT", "prevenir le PCT", "informer PCT", etc.
PCT_DETECTION_RE = re.compile(r"\bpct\b", re.IGNORECASE)
# Filtre OS à l'import : on ne garde que les Linux (Windows hors périmètre patcher).
WINDOWS_OS_RE = re.compile(r"\bwin(dows)?\b|microsoft", re.IGNORECASE)
def _is_skipped_os(os_str) -> bool:
"""Vrai si l'OS est explicitement Windows (ou Microsoft Server) — à exclure de l'import."""
if not os_str:
return False # OS vide → on garde, on ne sait pas
return bool(WINDOWS_OS_RE.search(str(os_str)))
def _detect_pct_required(rec: dict) -> bool:
"""Vrai si l'une des colonnes texte de la ligne mentionne 'PCT' en mot entier."""
for k in ("referent_technique", "mode_operatoire", "impacts", "commentaire"):
v = rec.get(k)
if v and PCT_DETECTION_RE.search(str(v)):
return True
return False
# ────────────────────────────────────────────────────────────────────────
# Normalisation casse / accent / variantes des champs taxonomiques
# (env, domaine) — pour éviter d'avoir 'Production' / 'production' / 'PROD' etc.
# en parallèle dans la BDD.
# ────────────────────────────────────────────────────────────────────────
ENV_CANONICAL = {
# Production
"production": "Production",
"prod": "Production",
"prd": "Production",
# Pré-Prod
"pré-prod": "Pré-Prod",
"pre-prod": "Pré-Prod",
"preprod": "Pré-Prod",
"pre prod": "Pré-Prod",
"pré prod": "Pré-Prod",
"pre-production": "Pré-Prod",
"pré-production": "Pré-Prod",
"preproduction": "Pré-Prod",
"préproduction": "Pré-Prod",
# Recette
"recette": "Recette",
"rec": "Recette",
"recettes": "Recette",
# Test (avec/sans numéro)
"test": "Test",
"tests": "Test",
"test 1": "Test 1",
"test1": "Test 1",
"test_1": "Test 1",
"test 2": "Test 2",
"test2": "Test 2",
"test_2": "Test 2",
# Dev
"développement": "Développement",
"developpement": "Développement",
"dev": "Développement",
"develop": "Développement",
# Qualif
"qualif": "Qualif",
"qualification": "Qualif",
}
DOMAIN_CANONICAL = {
"flux libre": "Flux Libre",
"flux-libre": "Flux Libre",
"fluxlibre": "Flux Libre",
"flux libre": "Flux Libre",
"péage": "Péage",
"peage": "Péage",
"infrastructure": "Infrastructure",
"infra": "Infrastructure",
"dmz": "DMZ",
"lan": "LAN",
"trafic": "Trafic",
"traffic": "Trafic",
"gestion": "Gestion",
"bi": "BI",
"emv": "EMV",
}
def _strip_accents_lower(s: str) -> str:
nfkd = unicodedata.normalize("NFKD", s)
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
def _canonicalize(v, mapping: dict):
"""Renvoie la forme canonique si v matche (case + accents insensitive),
sinon renvoie v inchangé (en strip).
Lookup en 2 passes : 1) lowercase exact, 2) sans accents."""
if v is None:
return None
s = str(v).strip()
if not s:
return s
low = s.lower()
if low in mapping:
return mapping[low]
no_acc = _strip_accents_lower(s)
if no_acc in mapping:
return mapping[no_acc]
# Aussi : compaction des espaces multiples (ex: "Flux Libre" -> "flux libre")
no_acc_compact = re.sub(r"\s+", " ", no_acc)
if no_acc_compact in mapping:
return mapping[no_acc_compact]
return s # pas de canonique connue, on garde tel quel
def _canonicalize_env(v):
return _canonicalize(v, ENV_CANONICAL)
def _canonicalize_domain(v):
return _canonicalize(v, DOMAIN_CANONICAL)
# Colonnes attendues dans les feuilles Sxx (ordre = priorité, on matche par regex/lower)
# Le fichier 2026 a 12 variantes d'en-têtes selon la semaine
# (ancien format S02-S06, nouveau format DTS S07+)
KNOWN_COLUMNS = {
"asset_name": [r"asset\s*name", r"\bnom\b"],
"intervenant": [r"intervenant"],
"environnement": [r"environnement|environement"],
"domaine": [r"^domaine"],
"os": [r"^\s*os\s*$"],
"os_version": [r"version\s*os"], # matche "Version OS" et "Version OS->Nom"
"application_name": [r"logiciel", r"application", r"^nom\s*complet$"],
"valideur_ra": [r"valideur"],
"responsable_domaine_dts":[r"responsable\s*domaine"],
"description": [r"description"],
"assistant": [r"^assistant"],
"referent_technique": [r"r.f.rent\s*tech"],
"mode_operatoire": [r"mode\s*op.ratoire"],
"impacts": [r"^impact"],
"commentaire": [r"commentaire"],
"base_de_donnees": [r"base\s*de\s*donn"],
"duree_coupure": [r"dur.+coupure"],
"jour": [r"^\s*jour\s*$", r"^\s*date\s*$", r"date\s*pr.+vis"],
"heure": [r"^\s*heure"],
"pb_espace_disque": [r"espace\s*disque"],
"date_patch_realise": [r"date\s*du?\s*patch.+r.+alis"],
}
SHEET_WEEK_RE = re.compile(r"^S\s*0?(\d+)$", re.IGNORECASE)
def _can_import(perms):
"""Droit d'importer = niveau edit/admin sur planning ou campaigns."""
return can_edit(perms, "planning") or can_edit(perms, "campaigns")
def _to_iso(v):
if v is None:
return None
if isinstance(v, (datetime, date)):
return v.isoformat()
return str(v)
def _coerce_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
return None
def _coerce_date_or_text(v):
"""Renvoie (date|None, fallback_text|None).
Si v est un datetime/date → (date, None).
Si v est une string parseable dd/mm/yyyy → (date, None).
Si v est une string non parseable (ex: "A partir du 14/01") → (None, str).
"""
if v is None or v == "":
return None, None
if isinstance(v, datetime):
return v.date(), None
if isinstance(v, date):
return v, None
if isinstance(v, str):
s = v.strip()
if not s:
return None, None
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{2,4})$", s)
if m:
try:
d = int(m.group(1)); mo = int(m.group(2)); y = int(m.group(3))
if y < 100:
y += 2000
return date(y, mo, d), None
except ValueError:
pass
return None, s
return None, str(v)
def _coerce_time(v):
"""Renvoie un datetime.time ou None.
Accepte: time, datetime, str '9H00', '12h30', '9:00', '14:00:00'.
"""
if v is None or v == "":
return None
if isinstance(v, time):
return v
if isinstance(v, datetime):
return v.time()
if isinstance(v, str):
s = v.strip()
m = re.match(r"^(\d{1,2})[Hh:](\d{2})(?::(\d{2}))?$", s)
if m:
try:
hh = int(m.group(1)); mm = int(m.group(2)); ss = int(m.group(3) or 0)
return time(hh, mm, ss)
except ValueError:
return None
return None
def _format_heure(v):
"""Renvoie une chaîne 'HHhMM' style SANEF pour affichage.
Si v est string, on garde tel quel (gère les cas '9H00 et 14H00').
Si v est time/datetime, on synthétise '9H00'.
"""
if v is None or v == "":
return None
if isinstance(v, str):
return v.strip()
if isinstance(v, time):
return f"{v.hour}H{v.minute:02d}"
if isinstance(v, datetime):
return f"{v.hour}H{v.minute:02d}"
return str(v)
def _coerce_bool(v):
if v is None or v == "":
return None
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("true", "vrai", "oui", "yes", "1", "x"):
return True
if s in ("false", "faux", "non", "no", "0"):
return False
return None
def _norm_header(h):
if h is None:
return ""
return re.sub(r"\s+", " ", str(h)).strip().lower()
def _build_column_map(headers):
"""Mappe l'index de colonne → nom logique (asset_name, intervenant, ...)."""
col_map = {}
used_logical = set()
for idx, h in enumerate(headers):
norm = _norm_header(h)
if not norm:
continue
for logical, patterns in KNOWN_COLUMNS.items():
if logical in used_logical:
continue
for pat in patterns:
if re.search(pat, norm):
col_map[idx] = logical
used_logical.add(logical)
break
if logical in used_logical:
break
return col_map
def _parse_sheet(ws, sheet_name):
"""Parse une feuille xlsx → liste de dict {logical_col: value, _raw: {header: value}}."""
rows_iter = ws.iter_rows(values_only=True)
try:
headers = next(rows_iter)
except StopIteration:
return [], []
headers = [h for h in headers]
col_map = _build_column_map(headers)
parsed = []
for ridx, row in enumerate(rows_iter, start=1):
if row is None:
continue
if all(c is None or (isinstance(c, str) and not c.strip()) for c in row):
continue
rec = {"row_index": ridx}
raw = {}
for cidx, val in enumerate(row):
header = headers[cidx] if cidx < len(headers) else f"col_{cidx}"
header_str = _norm_header(header) or f"col_{cidx}"
raw[header_str] = _to_iso(val)
if cidx in col_map:
rec[col_map[cidx]] = val
rec["_raw"] = raw
parsed.append(rec)
return headers, parsed
def _list_imports(db):
return db.execute(text("""
SELECT i.id, i.filename, i.year, i.sheet_count, i.row_count,
i.uploaded_at, u.username as uploaded_by_name
FROM patch_planning_imports i
LEFT JOIN users u ON u.id = i.uploaded_by
ORDER BY i.uploaded_at DESC
LIMIT 50
""")).fetchall()
# ────────────────────────────────────────────────────────────────────────
# Pages
# ────────────────────────────────────────────────────────────────────────
@router.get("/patching/import", response_class=HTMLResponse)
async def import_index(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return RedirectResponse(url="/dashboard")
imports = _list_imports(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"imports": imports,
"current_import": None,
"can_import": _can_import(perms),
"msg": request.query_params.get("msg"),
"err": request.query_params.get("err"),
})
return templates.TemplateResponse("patching_import.html", ctx)
@router.get("/patching/import/{import_id}", response_class=HTMLResponse)
async def import_view(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return RedirectResponse(url="/dashboard")
imp = db.execute(text("""
SELECT i.*, u.username as uploaded_by_name
FROM patch_planning_imports i
LEFT JOIN users u ON u.id = i.uploaded_by
WHERE i.id = :id
"""), {"id": import_id}).fetchone()
if not imp:
return RedirectResponse(url="/patching/import?err=notfound")
sheets = db.execute(text("""
SELECT sheet_name, week_number, COUNT(*) as nb
FROM patch_planning_import_rows
WHERE import_id = :id
GROUP BY sheet_name, week_number
ORDER BY week_number NULLS LAST, sheet_name
"""), {"id": import_id}).fetchall()
imports = _list_imports(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"imports": imports,
"current_import": imp,
"sheets": sheets,
"can_import": _can_import(perms),
"msg": request.query_params.get("msg"),
"err": request.query_params.get("err"),
})
return templates.TemplateResponse("patching_import.html", ctx)
# ────────────────────────────────────────────────────────────────────────
# JSON : rows d'une feuille
# ────────────────────────────────────────────────────────────────────────
@router.get("/patching/import/{import_id}/sheet/{sheet_name}")
async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
rows = db.execute(text("""
SELECT r.id, r.row_index, r.asset_name, r.intervenant, r.environnement,
r.domaine, r.os, r.os_version, r.application_name,
r.valideur_ra, r.responsable_domaine_dts,
r.description, r.assistant, r.referent_technique,
r.mode_operatoire, r.impacts, r.commentaire, r.base_de_donnees,
r.duree_coupure, r.jour, r.jour_text, r.heure, r.heure_t,
r.pb_espace_disque, r.date_patch_realise,
r.is_eligible, r.reported_to_sheet, r.report_reason,
r.server_id, s.hostname as resolved_hostname,
(r.jour + COALESCE(r.heure_t, TIME '00:00:00')) AS start_at
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
WHERE r.import_id = :id AND r.sheet_name = :sn
ORDER BY r.row_index
"""), {"id": import_id, "sn": sheet_name}).fetchall()
out = []
for r in rows:
out.append({
"id": r.id,
"row_index": r.row_index,
"asset_name": r.asset_name,
"intervenant": r.intervenant,
"environnement": r.environnement,
"domaine": r.domaine,
"os": r.os,
"os_version": r.os_version,
"application_name": r.application_name,
"valideur_ra": r.valideur_ra,
"responsable_domaine_dts": r.responsable_domaine_dts,
"description": r.description,
"assistant": r.assistant,
"referent_technique": r.referent_technique,
"mode_operatoire": r.mode_operatoire,
"impacts": r.impacts,
"commentaire": r.commentaire,
"base_de_donnees": r.base_de_donnees,
"duree_coupure": r.duree_coupure,
"jour": r.jour.isoformat() if r.jour else None,
"jour_text": r.jour_text,
"heure": r.heure,
"heure_t": r.heure_t.strftime("%H:%M:%S") if r.heure_t else None,
"start_iso": r.start_at.isoformat() if r.start_at else None,
"pb_espace_disque": r.pb_espace_disque,
"date_patch_realise": r.date_patch_realise.isoformat() if r.date_patch_realise else None,
"is_eligible": r.is_eligible,
"reported_to_sheet": r.reported_to_sheet,
"report_reason": r.report_reason,
"server_id": r.server_id,
"resolved_hostname": r.resolved_hostname,
})
return JSONResponse({"ok": True, "rows": out, "count": len(out)})
# ────────────────────────────────────────────────────────────────────────
# Action sur les rows : éligible / report
# ────────────────────────────────────────────────────────────────────────
@router.post("/patching/import/{import_id}/rows/action")
async def import_rows_action(request: Request, import_id: int, db=Depends(get_db)):
"""Pose une action sur N rows :
- action='eligible' : marque is_eligible=true (et clear report)
- action='unset_eligible' : remet is_eligible=false
- action='report' : reported_to_sheet=target_sheet, reason=... (clear is_eligible)
- action='unset_report' : clear report
"""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_import(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
row_ids = [int(x) for x in body.get("row_ids", []) if str(x).isdigit()]
action = (body.get("action") or "").strip()
target_sheet = (body.get("target_sheet") or "").strip()
reason = (body.get("reason") or "").strip()
if not row_ids:
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"})
if action not in ("eligible", "unset_eligible", "report", "unset_report"):
return JSONResponse({"ok": False, "msg": f"Action inconnue: {action}"})
if action == "report" and not target_sheet:
return JSONResponse({"ok": False, "msg": "Semaine cible obligatoire pour reporter"})
placeholders = ",".join(str(i) for i in row_ids)
# Restreint aux rows de cet import (sécurité)
rows = db.execute(text(f"""
SELECT id FROM patch_planning_import_rows
WHERE id IN ({placeholders}) AND import_id=:imp
"""), {"imp": import_id}).fetchall()
valid_ids = [r.id for r in rows]
if not valid_ids:
return JSONResponse({"ok": False, "msg": "Aucune ligne valide pour cet import"})
valid_ph = ",".join(str(i) for i in valid_ids)
uid = user.get("uid")
details = {}
if action == "eligible":
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET is_eligible=true, reported_to_sheet=NULL, report_reason=NULL,
last_action_at=NOW(), last_action_by=:uid
WHERE id IN ({valid_ph})
"""), {"uid": uid})
elif action == "unset_eligible":
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET is_eligible=false, last_action_at=NOW(), last_action_by=:uid
WHERE id IN ({valid_ph})
"""), {"uid": uid})
elif action == "report":
details = {"target_sheet": target_sheet, "reason": reason}
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET is_eligible=false, reported_to_sheet=:ts, report_reason=:rs,
last_action_at=NOW(), last_action_by=:uid
WHERE id IN ({valid_ph})
"""), {"ts": target_sheet, "rs": reason or None, "uid": uid})
elif action == "unset_report":
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET reported_to_sheet=NULL, report_reason=NULL,
last_action_at=NOW(), last_action_by=:uid
WHERE id IN ({valid_ph})
"""), {"uid": uid})
# Log
for rid in valid_ids:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, :ac, :de, :uid)
"""), {"rid": rid, "ac": action,
"de": json.dumps(details, ensure_ascii=False) if details else None,
"uid": uid})
db.commit()
return JSONResponse({"ok": True, "updated": len(valid_ids), "action": action})
# ────────────────────────────────────────────────────────────────────────
# Upload
# ────────────────────────────────────────────────────────────────────────
@router.post("/patching/import/upload")
async def import_upload(request: Request, db=Depends(get_db),
file: UploadFile = File(...),
note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
perms = get_user_perms(db, user)
if not _can_import(perms):
return RedirectResponse(url="/patching/import?err=denied", status_code=303)
fname = file.filename or "import.xlsx"
if not fname.lower().endswith(".xlsx"):
return RedirectResponse(url="/patching/import?err=ext", status_code=303)
try:
import openpyxl
except ImportError:
return RedirectResponse(url="/patching/import?err=openpyxl_missing", status_code=303)
content = await file.read()
try:
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
except Exception as e:
print(f"[import_upload] load_workbook failed: {e}")
return RedirectResponse(url="/patching/import?err=parse", status_code=303)
# Détecter l'année (depuis nom de fichier ou colonne 'jour' de la 1ère feuille semaine)
year_match = re.search(r"(20\d{2})", fname)
year = int(year_match.group(1)) if year_match else None
# Insert header (RETURNING id : evite le bug lastval() pollué par triggers)
import_id = db.execute(text("""
INSERT INTO patch_planning_imports (filename, year, sheet_count, row_count, uploaded_by, note)
VALUES (:fn, :y, 0, 0, :uid, :nt)
RETURNING id
"""), {"fn": fname, "y": year, "uid": user.get("uid"), "nt": note or None}).scalar()
db.commit()
if not import_id:
return RedirectResponse(url="/patching/import?err=insert_header", status_code=303)
sheet_count = 0
row_count = 0
skipped_windows = 0 # comptage des rows skippées (Windows)
# Pré-charge mapping hostname → server_id pour résolution
hostname_map = {}
for r in db.execute(text("SELECT id, hostname FROM servers")).fetchall():
if r.hostname:
hostname_map[r.hostname.lower().strip()] = r.id
for sheet_name in wb.sheetnames:
m = SHEET_WEEK_RE.match(sheet_name.strip())
if not m:
# On ignore les feuilles "Histo-XXX" et autres non-semaines
continue
week_num = int(m.group(1))
ws = wb[sheet_name]
_, parsed_rows = _parse_sheet(ws, sheet_name)
if not parsed_rows:
continue
sheet_count += 1
for rec in parsed_rows:
asset = rec.get("asset_name")
asset_str = str(asset).strip() if asset else None
if not asset_str:
continue
# Skip Windows : seules les Linux sont gérées par le workflow patching
if _is_skipped_os(rec.get("os")):
skipped_windows += 1
continue
sid = hostname_map.get(asset_str.lower())
jour_d, jour_t = _coerce_date_or_text(rec.get("jour"))
heure_t = _coerce_time(rec.get("heure"))
heure_disp = _format_heure(rec.get("heure"))
pct_req = _detect_pct_required(rec)
db.execute(text("""
INSERT INTO patch_planning_import_rows (
import_id, sheet_name, week_number, row_index,
asset_name, intervenant, environnement, domaine, os, os_version,
application_name, valideur_ra, responsable_domaine_dts,
description, assistant, referent_technique, mode_operatoire, impacts,
commentaire, base_de_donnees,
duree_coupure, jour, jour_text, heure, heure_t,
pb_espace_disque, date_patch_realise,
raw_data, server_id, pct_required
) VALUES (
:imp, :sn, :wn, :ri,
:an, :it, :en, :do, :os, :ov,
:ap, :vr, :rd,
:de, :as_, :rt, :mo, :im,
:co, :bdd,
:dc, :jr, :jt, :hr, :ht,
:pb, :dpr,
:raw, :sid, :pctr
)
"""), {
"imp": import_id, "sn": sheet_name, "wn": week_num, "ri": rec["row_index"],
"an": asset_str,
"it": str(rec.get("intervenant")) if rec.get("intervenant") else None,
"en": _canonicalize_env(rec.get("environnement")) if rec.get("environnement") else None,
"do": _canonicalize_domain(rec.get("domaine")) if rec.get("domaine") else None,
"os": str(rec.get("os")) if rec.get("os") else None,
"ov": str(rec.get("os_version")) if rec.get("os_version") else None,
"ap": str(rec.get("application_name")) if rec.get("application_name") else None,
"vr": str(rec.get("valideur_ra")) if rec.get("valideur_ra") else None,
"rd": str(rec.get("responsable_domaine_dts")) if rec.get("responsable_domaine_dts") else None,
"de": str(rec.get("description")) if rec.get("description") else None,
"as_": str(rec.get("assistant")) if rec.get("assistant") else None,
"rt": str(rec.get("referent_technique")) if rec.get("referent_technique") else None,
"mo": str(rec.get("mode_operatoire")) if rec.get("mode_operatoire") else None,
"im": str(rec.get("impacts")) if rec.get("impacts") else None,
"co": str(rec.get("commentaire")) if rec.get("commentaire") else None,
"bdd": str(rec.get("base_de_donnees")) if rec.get("base_de_donnees") else None,
"dc": str(rec.get("duree_coupure")) if rec.get("duree_coupure") else None,
"jr": jour_d,
"jt": jour_t,
"hr": heure_disp,
"ht": heure_t,
"pb": _coerce_bool(rec.get("pb_espace_disque")),
"dpr": _coerce_date(rec.get("date_patch_realise")),
"raw": json.dumps(rec.get("_raw") or {}, ensure_ascii=False, default=str),
"sid": sid,
"pctr": pct_req,
})
# Propage à servers.pct_required si serveur lié et pas déjà true
if pct_req and sid:
db.execute(text("""
UPDATE servers SET pct_required = true
WHERE id = :sid AND COALESCE(pct_required, false) = false
"""), {"sid": sid})
row_count += 1
db.execute(text("""
UPDATE patch_planning_imports SET sheet_count=:s, row_count=:r WHERE id=:id
"""), {"s": sheet_count, "r": row_count, "id": import_id})
# Append au champ note un récap des skips Windows
if skipped_windows > 0:
existing_note = db.execute(text(
"SELECT note FROM patch_planning_imports WHERE id=:id"
), {"id": import_id}).scalar() or ""
suffix = f"\n[skip-windows: {skipped_windows} ligne(s) Windows ignorée(s) à l'import]"
db.execute(text(
"UPDATE patch_planning_imports SET note = :n WHERE id = :id"
), {"n": (existing_note + suffix).strip(), "id": import_id})
db.commit()
return RedirectResponse(url=f"/patching/import/{import_id}?msg=ok", status_code=303)
# ────────────────────────────────────────────────────────────────────────
# Suppression
# ────────────────────────────────────────────────────────────────────────
# ────────────────────────────────────────────────────────────────────────
# Workflow iexec — placeholder étape B (à compléter)
# ────────────────────────────────────────────────────────────────────────
@router.get("/patching/iexec", response_class=HTMLResponse)
async def iexec_page(request: Request, db=Depends(get_db),
row_ids: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return RedirectResponse(url="/dashboard")
ids = [int(x) for x in row_ids.split(",") if x.strip().isdigit()]
rows = []
if ids:
placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f"""
SELECT r.id, r.asset_name, r.environnement, r.domaine, r.os, r.os_version,
r.intervenant,
r.is_eligible, r.server_id,
r.pct_required, r.pct_confirmed_at,
s.hostname, vs.effective_excludes,
s.skip_first_reboot, s.patching_notes,
sc.name AS cluster_name, sc.reboot_delay_min_minutes
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
LEFT JOIN v_servers vs ON vs.id = r.server_id
LEFT JOIN server_clusters sc ON sc.id = s.cluster_id
WHERE r.id IN ({placeholders}) AND r.is_eligible = true
""")).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"rows": rows,
"row_ids": ids,
})
return templates.TemplateResponse("patching_iexec.html", ctx)
@router.post("/patching/iexec/confirm-pct")
async def iexec_confirm_pct(request: Request, db=Depends(get_db),
row_ids: str = Form(...)):
"""Marque une liste de patch_planning_import_rows comme PCT confirmé.
Met à jour pct_confirmed_at + pct_confirmed_by_user_id.
row_ids = CSV d'IDs."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not (can_edit(perms, "planning") or can_edit(perms, "campaigns")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
ids = [int(x) for x in row_ids.split(",") if x.strip().isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucune ligne ciblée"}, status_code=400)
placeholders = ",".join(str(i) for i in ids)
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET pct_confirmed_at = now(), pct_confirmed_by_user_id = :uid
WHERE id IN ({placeholders})
AND pct_required = true
AND pct_confirmed_at IS NULL
"""), {"uid": user.get("id")})
db.commit()
return JSONResponse({"ok": True, "confirmed_ids": ids})
@router.post("/patching/iexec/check/{row_id}")
async def iexec_check(request: Request, row_id: int, db=Depends(get_db)):
"""Lance les 3 checks pré-patching (DNS, SSH, Satellite) sur 1 row éligible.
Retourne JSON avec le résultat détaillé."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
row = db.execute(text("""
SELECT r.id, r.asset_name, r.intervenant, r.environnement, r.domaine,
r.os, r.os_version, r.is_eligible, r.server_id,
s.hostname, s.satellite_url
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
WHERE r.id = :id
"""), {"id": row_id}).fetchone()
if not row:
return JSONResponse({"ok": False, "msg": "Ligne introuvable"}, status_code=404)
if not row.is_eligible:
return JSONResponse({"ok": False, "msg": "Ligne non éligible"}, status_code=400)
# Workflow yum/Satellite = Linux uniquement
os_str = str(row.os or "").lower()
if "windows" in os_str or os_str.strip() == "win":
return JSONResponse({
"ok": True, "row_id": row_id,
"hostname": row.asset_name, "target": None,
"overall": "unsupported",
"checks": [],
"skipped_reason": f"OS '{row.os}' non concerné — workflow Linux uniquement",
})
hostname = (row.hostname or row.asset_name or "").strip()
if not hostname:
return JSONResponse({"ok": False, "msg": "Pas de hostname"}, status_code=400)
from ..services.prepatch_check_service import run_all_checks
result = run_all_checks(hostname, row={
"asset_name": row.asset_name,
"intervenant": row.intervenant,
"environnement": row.environnement,
"domaine": row.domaine,
"satellite_url": getattr(row, "satellite_url", None),
})
return JSONResponse({"ok": True, "row_id": row_id, **result})
@router.post("/patching/iexec/snapshot/{row_id}")
async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)):
"""Step 2 — prend un snapshot vCenter pour 1 row éligible Linux.
Nom snapshot : <user>_<YYYY-MM-DD>_<HH-MM>_avant_patch
(user = login du compte connecté, traçable, immutable).
Réutilise quickwin_snapshot_service.snapshot_server."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_import(perms):
return JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403)
row = db.execute(text("""
SELECT r.id, r.asset_name, r.intervenant, r.environnement, r.os, r.is_eligible,
s.hostname, s.vcenter_vm_name
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
WHERE r.id = :id
"""), {"id": row_id}).fetchone()
if not row:
return JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404)
if not row.is_eligible:
return JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400)
os_str = str(row.os or "").lower()
if "windows" in os_str:
return JSONResponse({"ok": False, "detail": "OS Windows non géré"}, status_code=400)
hostname = (row.hostname or row.asset_name or "").strip()
if not hostname:
return JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400)
# Branche prod / hprod basée sur le préfixe hostname (convention SANEF) :
# vp/sp/lp → prod → vCenter "metier" (Nanterre, vpmetavcs1)
# le reste → hprod → vCenter "gestion" (Senlis, vpgesavcs1)
# Plus fiable que la colonne environnement Excel (peut être bug-prone).
prefix = (hostname.split(".")[0] or "").lower()[:2]
PROD_PREFIXES = ("vp", "sp", "lp")
branch = "prod" if prefix in PROD_PREFIXES else "hprod"
# Nom snapshot : <user_connecté>_<YYYY-MM-DD>_<HH-MM>_avant_patch
# On utilise le username du JWT (sub) — login immutable, traçable par compte AD.
# Heure ajoutée pour éviter collision si on patche le même serveur 2x dans la journée.
snap_user = ((user.get("sub") or user.get("username") or "patcheur")
.strip().replace(" ", "_"))
snap_dt = datetime.now().strftime("%Y-%m-%d_%H-%M")
snap_name = f"{snap_user}_{snap_dt}_avant_patch"
# On cherche la VM dans vCenter par son hostname (pas par s.vcenter_vm_name
# qui peut être faux en base). Si plus tard on a un cas où la VM porte un
# nom différent, on rajoutera un mapping explicite.
vm_name = hostname
from ..services.quickwin_snapshot_service import snapshot_server
result = snapshot_server(hostname, vm_name, branch, db, snap_name=snap_name)
# result = {ok, vcenter, detail, skipped?}
# Audit log
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'snapshot', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({**result, "snap_name": snap_name, "branch": branch,
"vm_name": vm_name},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
log_msg = f"audit log snapshot failed: {e}"
print(f"[iexec_snapshot] {log_msg}")
result.update({
"row_id": row_id,
"snap_name": snap_name,
"branch": branch,
"vm_name": vm_name,
})
return JSONResponse(result)
# ────────────────────────────────────────────────────────────────────────
# Prévenance PCT — envoi d'un mail à PCT.reims@sanef.com pour annoncer
# une intervention sur un ou plusieurs serveurs sélectionnés.
# ────────────────────────────────────────────────────────────────────────
def _build_pct_email(rows, intervenant_name=""):
"""Construit (subject, html_body) pour la prévenance PCT à partir de rows.
rows = list de tuples (asset_name, application_name, jour, heure, environnement, domaine).
"""
from datetime import datetime as _dt
# Détermine le sujet
apps = [r["application_name"] for r in rows if r.get("application_name")]
distinct_apps = sorted(set(a.strip() for a in apps if a and a.strip()))
server_names = [r["asset_name"] for r in rows if r.get("asset_name")]
if len(distinct_apps) == 1:
target_label = distinct_apps[0]
elif len(distinct_apps) > 1:
target_label = ", ".join(distinct_apps)
elif server_names:
target_label = ", ".join(server_names)
else:
target_label = "(serveurs non identifiés)"
subject = f"Intervention sur {target_label}"
# Plage horaire = 20 min × N serveurs
n = len(rows)
plage_min = n * 20
h = plage_min // 60
m = plage_min % 60
if h and m:
plage_str = f"{h}h{m:02d} (≈ {n} serveur(s) × 20 min)"
elif h:
plage_str = f"{h}h00 (≈ {n} serveur(s) × 20 min)"
else:
plage_str = f"{m} min (≈ {n} serveur(s) × 20 min)"
# Liste serveurs avec date/heure
def _fmt_dh(jour, heure):
if not jour and not heure:
return "(non planifié)"
d_str = ""
if jour:
try:
d_str = jour.strftime("%d/%m/%Y") if hasattr(jour, "strftime") else str(jour)
except Exception:
d_str = str(jour)
h_str = ""
if heure:
try:
h_str = heure.strftime("%H:%M") if hasattr(heure, "strftime") else str(heure)
except Exception:
h_str = str(heure)
return (d_str + (" à " + h_str if h_str else "")).strip() or "(non planifié)"
serv_table_rows = ""
for r in rows:
srv = r.get("asset_name") or "?"
dh = _fmt_dh(r.get("jour"), r.get("heure_t"))
env = r.get("environnement") or ""
app = r.get("application_name") or ""
serv_table_rows += (
f'<tr style="border-bottom:1px solid #e5e7eb;">'
f'<td style="padding:10px 14px;font-family:monospace;color:#1e3a8a;">{srv}</td>'
f'<td style="padding:10px 14px;color:#374151;">{app}</td>'
f'<td style="padding:10px 14px;color:#6b7280;">{env}</td>'
f'<td style="padding:10px 14px;color:#374151;">{dh}</td>'
f'</tr>'
)
localisations = ", ".join(server_names) if server_names else "(non identifié)"
equip_service = ", ".join(distinct_apps) if distinct_apps else "(non identifié)"
# Body HTML pro/coloré (compatible Outlook : tout en inline CSS)
html = f"""<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:'Segoe UI',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f4f6;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="720" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background:linear-gradient(90deg,#1e3a8a 0%,#1e40af 100%);padding:24px 32px;color:#ffffff;">
<div style="font-size:11px;letter-spacing:0.15em;text-transform:uppercase;opacity:0.85;">SANEF — SecOps</div>
<h1 style="margin:6px 0 0;font-size:22px;font-weight:600;letter-spacing:-0.01em;">Prévenance d'intervention PCT</h1>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px;color:#1f2937;font-size:14px;line-height:1.6;">
<p style="margin:0 0 16px;">Bonjour,</p>
<p style="margin:0 0 24px;">
Dans le cadre des corrections de vulnérabilités du SI SANEF, une intervention est prévue sur
<strong style="color:#1e3a8a;">{target_label}</strong>.
</p>
<!-- Card infos -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
style="border-left:4px solid #2563eb;background:#eff6ff;border-radius:4px;margin:0 0 24px;">
<tr><td style="padding:16px 20px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="padding:4px 0;color:#6b7280;font-weight:600;width:40%;">Nature de l'intervention :</td>
<td style="padding:4px 0;color:#1f2937;">Mise à jour du serveur plus redémarrage</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Type d'intervention :</td>
<td style="padding:4px 0;color:#1f2937;">Correctif</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Localisation :</td>
<td style="padding:4px 0;color:#1f2937;font-family:monospace;">{localisations}</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Équipement / service :</td>
<td style="padding:4px 0;color:#1f2937;">{equip_service}</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Plage horaire estimée :</td>
<td style="padding:4px 0;color:#1f2937;"><strong>{plage_str}</strong></td>
</tr>
</table>
</td></tr>
</table>
<!-- Tableau serveurs -->
<h3 style="margin:24px 0 12px;font-size:13px;letter-spacing:0.08em;text-transform:uppercase;color:#374151;">
Détail des interventions ({n} serveur{'s' if n>1 else ''})
</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:4px;overflow:hidden;font-size:13px;">
<thead><tr style="background:#f9fafb;border-bottom:2px solid #e5e7eb;">
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Serveur</th>
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Application</th>
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Env.</th>
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Date / Heure</th>
</tr></thead>
<tbody>{serv_table_rows}</tbody>
</table>
<!-- Impact -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
style="border-left:4px solid #f59e0b;background:#fffbeb;border-radius:4px;margin:24px 0 0;">
<tr><td style="padding:14px 20px;color:#92400e;font-size:13px;">
<strong>Impact :</strong> interruption d'environ <strong>5 minutes par serveur</strong> au moment du redémarrage.
</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:16px 0 0;">
<tr>
<td style="padding:6px 0;color:#6b7280;font-weight:600;width:50%;">Moyen d'exploitation à prévoir :</td>
<td style="padding:6px 0;color:#1f2937;">Aucun</td>
</tr>
<tr>
<td style="padding:6px 0;color:#6b7280;font-weight:600;">Upgrade réalisé par :</td>
<td style="padding:6px 0;color:#1f2937;">{intervenant_name or 'SecOps'}</td>
</tr>
<tr>
<td style="padding:6px 0;color:#6b7280;font-weight:600;">Moyen d'exploitation prévu :</td>
<td style="padding:6px 0;color:#1f2937;"><strong>Rollback en cas de problème</strong></td>
</tr>
</table>
<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">
Cordialement,<br>
L'équipe SecOps SANEF
</p>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f9fafb;padding:14px 32px;border-top:1px solid #e5e7eb;color:#9ca3af;font-size:11px;">
Mail généré automatiquement par <strong>PatchCenter</strong>. Pour toute question : équipe SecOps.
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
return subject, html
def _fetch_pct_cc_emails(db, row_ids):
"""Récupère les emails distincts (responsable domaine + référent technique
+ référents additionnels) pour les rows sélectionnées, à mettre en CC du mail PCT.
Cherche dans 3 sources, dédoublonne par email :
1. FK contacts via servers.responsable_domaine_contact_id / referent_technique_contact_id
2. Table server_additional_referents (multi-référents)
3. Champs legacy texte servers.responsable_email / referent_email
(avec recherche du nom complet dans contacts si dispo)"""
if not row_ids:
return []
placeholders = ",".join(str(i) for i in row_ids)
seen = set()
out = []
def _add(name, email):
em = (email or "").strip()
if not em:
return
key = em.lower()
if key in seen:
return
seen.add(key)
out.append({"name": (name or em).strip(), "email": em})
# 1) Via FK contact_id (modèle moderne) + 2) référents additionnels
fk_rows = db.execute(text(f"""
SELECT DISTINCT c.name, c.email FROM contacts c
WHERE c.email IS NOT NULL AND c.email <> ''
AND c.id IN (
SELECT s.responsable_domaine_contact_id
FROM patch_planning_import_rows r JOIN servers s ON s.id = r.server_id
WHERE r.id IN ({placeholders}) AND s.responsable_domaine_contact_id IS NOT NULL
UNION
SELECT s.referent_technique_contact_id
FROM patch_planning_import_rows r JOIN servers s ON s.id = r.server_id
WHERE r.id IN ({placeholders}) AND s.referent_technique_contact_id IS NOT NULL
UNION
SELECT sar.contact_id
FROM patch_planning_import_rows r
JOIN server_additional_referents sar ON sar.server_id = r.server_id
WHERE r.id IN ({placeholders})
)
""")).fetchall()
for c in fk_rows:
_add(c.name, c.email)
# 3) Champs legacy texte sur servers (responsable_email / referent_email)
legacy = db.execute(text(f"""
SELECT DISTINCT s.responsable_nom, s.responsable_email,
s.referent_nom, s.referent_email,
r.responsable_domaine_dts, r.referent_technique
FROM patch_planning_import_rows r
JOIN servers s ON s.id = r.server_id
WHERE r.id IN ({placeholders})
""")).fetchall()
legacy_names = set() # noms à matcher dans contacts si email manquant
for row in legacy:
_add(row.responsable_nom, row.responsable_email)
_add(row.referent_nom, row.referent_email)
# Si email vide côté servers, on retiendra le nom pour matcher dans contacts
if not (row.responsable_email or "").strip() and (row.responsable_nom or "").strip():
legacy_names.add(row.responsable_nom.strip())
if not (row.referent_email or "").strip() and (row.referent_nom or "").strip():
legacy_names.add(row.referent_nom.strip())
# Aussi : noms texte côté patch_planning_import_rows (responsable_domaine_dts,
# referent_technique) — peuvent contenir plusieurs noms séparés par '/' ou '-'
for nm_field in (row.responsable_domaine_dts, row.referent_technique):
if not nm_field:
continue
for part in re.split(r"[/,;\n]| - | et | ou | & ", str(nm_field)):
part = part.strip()
if len(part) >= 4 and re.search(r"[A-Za-zÀ-ÿ]{3}", part):
legacy_names.add(part)
# 4) Match par nom dans la table contacts pour les responsables/référents
# qui n'ont pas d'email côté servers. Match insensible casse + accents.
if legacy_names:
# On construit une condition ILIKE pour chaque nom + ses variantes simples
names_list = list(legacy_names)[:50] # limite de sécurité
params = {}
ors = []
for i, n in enumerate(names_list):
params[f"n{i}"] = n
# Match exact (case insensible) ou nom contenu (utile pour 'DUFOUR Antoine' vs 'Antoine Dufour')
ors.append(f"LOWER(c.name) = LOWER(:n{i})")
ors.append(f"LOWER(c.name) LIKE LOWER('%' || :n{i} || '%')")
sql = (
"SELECT DISTINCT c.name, c.email FROM contacts c "
"WHERE c.email IS NOT NULL AND c.email <> '' "
f"AND ({' OR '.join(ors)})"
)
try:
matched = db.execute(text(sql), params).fetchall()
for c in matched:
_add(c.name, c.email)
except Exception as e:
print(f"[pct_cc] match par nom failed: {e}")
out.sort(key=lambda c: c["name"].lower())
return out
@router.post("/patching/import/pct-prevenance/preview")
async def pct_prevenance_preview(request: Request, db=Depends(get_db)):
"""Construit l'aperçu mail PCT pour les rows sélectionnées sans envoyer.
Body JSON : {row_ids: [int, ...]}"""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
try:
body = await request.json()
except Exception:
return JSONResponse({"ok": False, "msg": "Body JSON invalide"}, status_code=400)
ids = [int(x) for x in (body.get("row_ids") or []) if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"}, status_code=400)
placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f"""
SELECT asset_name, application_name, environnement, domaine,
jour, heure_t, intervenant
FROM patch_planning_import_rows
WHERE id IN ({placeholders})
ORDER BY jour NULLS LAST, heure_t NULLS LAST, asset_name
""")).fetchall()
if not rows:
return JSONResponse({"ok": False, "msg": "Lignes introuvables"}, status_code=404)
rows_dicts = [
{"asset_name": r.asset_name, "application_name": r.application_name,
"environnement": r.environnement, "domaine": r.domaine,
"jour": r.jour, "heure_t": r.heure_t, "intervenant": r.intervenant}
for r in rows
]
intervenant_name = (rows[0].intervenant or user.get("sub") or "SecOps")
subject, html = _build_pct_email(rows_dicts, intervenant_name=intervenant_name)
from ..services.mail_service import get_smtp_config
smtp = get_smtp_config(db)
from ..services.secrets_service import get_secret
pct_recipient = (get_secret(db, "smtp_pct_recipient") or "PCT.reims@sanef.com").strip()
cc_contacts = _fetch_pct_cc_emails(db, ids)
return JSONResponse({
"ok": True,
"subject": subject,
"html": html,
"to": pct_recipient,
"cc": cc_contacts, # [{name, email}]
"smtp_configured": smtp is not None,
"row_count": len(rows),
})
@router.post("/patching/import/pct-prevenance/send")
async def pct_prevenance_send(request: Request, db=Depends(get_db)):
"""Envoie réellement le mail PCT pour les rows sélectionnées.
Body JSON : {row_ids: [int, ...]}"""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not (can_edit(perms, "planning") or can_edit(perms, "campaigns")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
try:
body = await request.json()
except Exception:
return JSONResponse({"ok": False, "msg": "Body JSON invalide"}, status_code=400)
ids = [int(x) for x in (body.get("row_ids") or []) if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"}, status_code=400)
placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f"""
SELECT id, asset_name, application_name, environnement, domaine,
jour, heure_t, intervenant
FROM patch_planning_import_rows
WHERE id IN ({placeholders})
ORDER BY jour NULLS LAST, heure_t NULLS LAST, asset_name
""")).fetchall()
if not rows:
return JSONResponse({"ok": False, "msg": "Lignes introuvables"}, status_code=404)
rows_dicts = [
{"asset_name": r.asset_name, "application_name": r.application_name,
"environnement": r.environnement, "domaine": r.domaine,
"jour": r.jour, "heure_t": r.heure_t, "intervenant": r.intervenant}
for r in rows
]
intervenant_name = (rows[0].intervenant or user.get("sub") or "SecOps")
subject, html = _build_pct_email(rows_dicts, intervenant_name=intervenant_name)
from ..services.secrets_service import get_secret
pct_recipient = (get_secret(db, "smtp_pct_recipient") or "PCT.reims@sanef.com").strip()
cc_list = [c["email"] for c in _fetch_pct_cc_emails(db, ids)]
from ..services.mail_service import send_html_mail
res = send_html_mail(db, to=[pct_recipient], cc=cc_list, subject=subject, html=html)
res["cc"] = cc_list
# Audit log
if res.get("ok"):
try:
for rid in ids:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'pct_prevenance', :de, :uid)
"""), {"rid": rid,
"de": json.dumps({"to": pct_recipient, "subject": subject},
ensure_ascii=False),
"uid": user.get("uid")})
# Marque les rows
db.execute(text(f"""
UPDATE patch_planning_import_rows
SET pct_mail_sent_at = now()
WHERE id IN ({placeholders})
"""))
db.commit()
except Exception as e:
print(f"[pct_prevenance] audit log failed: {e}")
return JSONResponse(res)
"""Charge la row et applique les vérifs communes (éligible + Linux + hostname).
Retourne (row_obj, error_response). error_response is None si OK."""
if not _can_import(perms):
return None, JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403)
row = db.execute(text("""
SELECT r.id, r.asset_name, r.os, r.is_eligible,
s.hostname, vs.effective_excludes
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
LEFT JOIN v_servers vs ON vs.id = r.server_id
WHERE r.id = :id
"""), {"id": row_id}).fetchone()
if not row:
return None, JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404)
if not row.is_eligible:
return None, JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400)
os_str = str(row.os or "").lower()
if "windows" in os_str:
return None, JSONResponse({"ok": False, "detail": "OS Windows non géré"}, status_code=400)
if not (row.hostname or row.asset_name):
return None, JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400)
return row, None
@router.post("/patching/iexec/yum-dryrun/{row_id}")
async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import yum_dryrun
result = yum_dryrun(hostname, row.effective_excludes)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'yum_dryrun', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_yum_dryrun] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/iexec/pre-capture/{row_id}")
async def iexec_pre_capture(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3b — capture services + ports avant patch (wiki SANEF)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import pre_patch_capture
result = pre_patch_capture(hostname)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'pre_patch_capture', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_pre_capture] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/iexec/post-compare/{row_id}")
async def iexec_post_compare(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3d — compare services+ports avant/après patch + rapport (wiki SANEF)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import post_patch_compare
result = post_patch_compare(hostname)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'post_patch_compare', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_post_compare] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.get("/patching/iexec/yum-stream/{row_id}")
async def iexec_yum_stream(request: Request, row_id: int,
mode: str = Query("dryrun"),
extra_excludes: str = Query(""),
db=Depends(get_db)):
"""Streaming SSE du yum dryrun ou update + audit log à la fin.
extra_excludes : liste séparée par espaces ou virgules (retry après dep KO).
"""
from fastapi.responses import StreamingResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
if mode not in ("dryrun", "update"):
return JSONResponse({"ok": False, "detail": "mode invalide"}, status_code=400)
hostname = (row.hostname or row.asset_name).strip()
excludes_raw = (row.effective_excludes or "")
if extra_excludes:
# Normalise virgules → espaces, fusionne
extra_norm = re.sub(r"[,;]+", " ", extra_excludes)
excludes_raw = (excludes_raw + " " + extra_norm).strip()
uid = user.get("uid")
action_name = "yum_dryrun_stream" if mode == "dryrun" else "yum_update_stream"
from ..services.patch_run_service import yum_stream_lines
def event_stream():
full_lines = []
cmd_used = ""
rc_final = -1
for ev in yum_stream_lines(hostname, excludes_raw, mode):
if ev.get("type") == "cmd":
cmd_used = ev.get("cmd", "")
elif ev.get("type") == "line":
full_lines.append(ev["data"])
elif ev.get("type") == "end":
rc_final = ev.get("rc", -1)
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
# Audit log final (best-effort, ne casse pas le stream)
try:
full = "\n".join(full_lines)
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, :ac, :de, :uid)
"""), {"rid": row_id, "ac": action_name,
"de": json.dumps({"cmd": cmd_used, "rc": rc_final,
"stdout_tail": full[-5000:]},
ensure_ascii=False),
"uid": uid})
db.commit()
except Exception as e:
print(f"[iexec_yum_stream] audit log failed: {e}")
return StreamingResponse(event_stream(), media_type="text/event-stream",
headers={"X-Accel-Buffering": "no",
"Cache-Control": "no-cache"})
@router.post("/patching/iexec/yum-update/{row_id}")
async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3 — vrai patch : `sudo -n yum update -y --exclude=...`."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import yum_update
result = yum_update(hostname, row.effective_excludes)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'yum_update', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_yum_update] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/iexec/reboot/{row_id}")
async def iexec_reboot(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3e — lance reboot avec délai +1 minute.
NE JAMAIS appeler en automatique : doit toujours venir d'un clic
utilisateur explicite (double confirmation côté UI)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import reboot_host
result = reboot_host(hostname)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'reboot_initiated', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps(result, ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_reboot] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.get("/patching/iexec/reboot-status/{row_id}")
async def iexec_reboot_status(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3e — poll l'état du serveur après reboot (TCP/22 + SSH 'uptime').
Appelé en boucle côté frontend (toutes les 10s)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import reboot_status
result = reboot_status(hostname)
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/import/{import_id}/delete")
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
perms = get_user_perms(db, user)
if not _can_import(perms):
return RedirectResponse(url="/patching/import?err=denied", status_code=303)
db.execute(text("DELETE FROM patch_planning_imports WHERE id=:id"), {"id": import_id})
db.commit()
return RedirectResponse(url="/patching/import?msg=deleted", status_code=303)