- auth.py: flag Secure + path=/ sur le cookie d'authentification - ldap_service.py: logging debug des connexions LDAPS vers logs/ldap_debug.log (jamais les mots de passe) - .gitignore: protege cles/certs TLS (ssl/, *.key, *.crt) + artefacts lourds (db/, sitepkgs.zip, *.bak, dump) - inclut aussi des modifs en cours: planning_import, patch_run_service, patching_iexec Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1728 lines
74 KiB
Python
1728 lines
74 KiB
Python
"""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 parsing du nom complet → email SANEF (format prenom.nom@sanef.com)
|
||
# Le contacts.name en BDD est juste le NOM DE FAMILLE (ex 'DELCOUR'),
|
||
# et il y a souvent plusieurs DELCOUR — il faut matcher ET le nom ET le prénom.
|
||
if legacy_names:
|
||
pairs = [] # liste de (prenom, nom)
|
||
for raw in legacy_names:
|
||
# Nettoie suffixes parasites courants : "- PCT", "- DBA", "(externe)", etc.
|
||
s = re.sub(r"\s*[\(\[].*?[\)\]]\s*", " ", raw)
|
||
s = re.sub(r"\s*-\s*[A-Z]{2,5}\s*$", "", s).strip() # "- PCT" en fin
|
||
if not s:
|
||
continue
|
||
tokens = re.split(r"\s+", s)
|
||
tokens = [t for t in tokens if t and t not in ("PCT", "DBA", "ext", "Ext")]
|
||
if len(tokens) < 2:
|
||
continue
|
||
# Identifie le nom de famille = token UPPERCASE de longueur >= 3
|
||
nom = None
|
||
for t in tokens:
|
||
# Match nom de famille : uniquement majuscules ASCII, longueur >= 3
|
||
# (gère 'DUFOUR', 'DELCOUR', 'GRAFFAGNINO' ; pas 'PCT' qui est trop court)
|
||
tt = t.replace("-", "").replace("'", "")
|
||
if len(tt) >= 3 and tt.isupper():
|
||
nom = t
|
||
break
|
||
if nom:
|
||
# Prénom = 1er token qui n'est pas le nom (souvent le 1er token)
|
||
prenom = next((t for t in tokens if t != nom), None)
|
||
else:
|
||
# Pas de UPPERCASE détecté : on suppose <Prénom> <Nom> et prend dernier token
|
||
prenom = tokens[0]
|
||
nom = tokens[-1]
|
||
if prenom and nom and prenom != nom:
|
||
pairs.append((prenom, nom))
|
||
|
||
# Pour chaque (prenom, nom), match strict sur email SANEF
|
||
for prenom, nom in pairs[:50]: # limite de sécurité
|
||
# email format SANEF : prenom.nom@... (insensible casse, accents non gérés en BDD)
|
||
email_pattern = f"{prenom.lower()}.{nom.lower()}@%"
|
||
try:
|
||
matched = db.execute(text("""
|
||
SELECT name, email FROM contacts
|
||
WHERE email IS NOT NULL AND email <> ''
|
||
AND LOWER(email) LIKE :pat
|
||
"""), {"pat": email_pattern}).fetchall()
|
||
for c in matched:
|
||
_add(f"{prenom} {nom}", c.email)
|
||
except Exception as e:
|
||
print(f"[pct_cc] match {prenom}.{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/download-eml")
|
||
async def pct_prevenance_download_eml(request: Request, db=Depends(get_db)):
|
||
"""Génère un fichier .eml (MIME) prêt à ouvrir dans le client mail par défaut.
|
||
Solution alternative quand SMTP / Outlook COM bloqués (firewall corp, pas d'Outlook desktop).
|
||
Body JSON : {row_ids: [int, ...]}"""
|
||
from fastapi.responses import Response
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from email.utils import formataddr, formatdate, make_msgid
|
||
|
||
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.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)]
|
||
sender_email = (get_secret(db, "smtp_from") or "").strip() or f"{user.get('sub') or 'patchcenter'}@sanef.com"
|
||
|
||
# Construit le MIME .eml
|
||
msg = MIMEMultipart("alternative")
|
||
msg["Subject"] = subject
|
||
msg["From"] = sender_email
|
||
msg["To"] = pct_recipient
|
||
if cc_list:
|
||
msg["Cc"] = ", ".join(cc_list)
|
||
msg["Date"] = formatdate(localtime=True)
|
||
msg["Message-ID"] = make_msgid(domain="patchcenter.sanef.local")
|
||
msg["X-Mailer"] = "PatchCenter (eml export)"
|
||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||
|
||
eml_bytes = msg.as_bytes()
|
||
# Nom de fichier : timestamp + nb serveurs
|
||
from datetime import datetime as _dt
|
||
ts = _dt.now().strftime("%Y%m%d_%H%M%S")
|
||
fname = f"prevenance_pct_{ts}_{len(rows)}srv.eml"
|
||
|
||
return Response(
|
||
content=eml_bytes,
|
||
media_type="message/rfc822",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{fname}"',
|
||
},
|
||
)
|
||
|
||
|
||
@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
|
||
|
||
|
||
def _common_iexec_row_check(row_id, db, user, perms):
|
||
"""Validation commune des endpoints d'exécution iexec (dry-run, yum, capture,
|
||
reboot, status…). Retourne (row, None) si OK, sinon (None, JSONResponse).
|
||
La row expose hostname, asset_name et effective_excludes (via v_servers),
|
||
attributs utilisés par tous les appelants."""
|
||
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
|
||
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, r.server_id,
|
||
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)
|
||
hostname = (row.hostname or row.asset_name or "").strip()
|
||
if not hostname:
|
||
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)
|