feat(pct): workflow prevenance PCT (auto-detection + gate confirmation + suffixe Teams)

- Migration migrate_pct_workflow_20260507.sql: ajoute patch_planning_import_rows
  pct_required (boolean default false), pct_confirmed_at (timestamptz),
  pct_confirmed_by_user_id (FK users). Backfill depuis servers.pct_required.
- Auto-detection a l'import (planning_import.py): scan referent_technique +
  mode_operatoire + impacts + commentaire pour pattern \bPCT\b mot entier
  (insensible casse) -> pct_required=true sur la row. Propage egalement vers
  servers.pct_required si pas deja true.
- UI iexec: badge orange '⚠ Prév PCT à faire' sur la cellule asset_name si
  pct_required=true et pas confirme, badge vert ' PCT ok' une fois confirme.
- Gate avant Step 3 (PATCH REEL): scan des serveurs cibles, si certains ont
  pct_required && !pct_confirmed -> 2 confirmations successives + appel
  POST /patching/iexec/confirm-pct qui marque pct_confirmed_at + user_id.
  Ne lance pas le patch si l'operateur annule.
- Endpoint POST /patching/iexec/confirm-pct: marque les rows comme PCT confirmes
  (pct_confirmed_at = now(), pct_confirmed_by_user_id = current user).
- Notif Teams: send_notification accepte planning_row_id optionnel ; si la row
  a pct_required && pct_confirmed, le message debut/fin est suffixe par
  ' (Prévenance PCT ok)' pour informer le responsable que l'amont a ete gere.
This commit is contained in:
Pierre & Lumière 2026-05-07 08:19:19 +02:00
parent 060af01db9
commit 29f6153370
4 changed files with 167 additions and 11 deletions

View File

@ -23,6 +23,20 @@ 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)
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
# 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+)
@ -513,6 +527,7 @@ async def import_upload(request: Request, db=Depends(get_db),
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,
@ -522,7 +537,7 @@ async def import_upload(request: Request, db=Depends(get_db),
commentaire, base_de_donnees,
duree_coupure, jour, jour_text, heure, heure_t,
pb_espace_disque, date_patch_realise,
raw_data, server_id
raw_data, server_id, pct_required
) VALUES (
:imp, :sn, :wn, :ri,
:an, :it, :en, :do, :os, :ov,
@ -531,7 +546,7 @@ async def import_upload(request: Request, db=Depends(get_db),
:co, :bdd,
:dc, :jr, :jt, :hr, :ht,
:pb, :dpr,
:raw, :sid
:raw, :sid, :pctr
)
"""), {
"imp": import_id, "sn": sheet_name, "wn": week_num, "ri": rec["row_index"],
@ -560,7 +575,14 @@ async def import_upload(request: Request, db=Depends(get_db),
"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
@ -596,6 +618,7 @@ async def iexec_page(request: Request, db=Depends(get_db),
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
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
@ -612,6 +635,33 @@ async def iexec_page(request: Request, db=Depends(get_db),
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.

View File

@ -33,18 +33,24 @@ PROXY_URL = None # à override via Settings si besoin
# MODE SHAREPOINT — écriture fichier dans dossier OneDrive sync
# ───────────────────────────────────────────────────────────────
def format_message_text(server_name: str, msg_type: str, intervenant: str) -> str:
def format_message_text(server_name: str, msg_type: str, intervenant: str,
pct_confirmed: bool = False) -> str:
"""Formate le message texte plat (compatible workflow PA → Teams).
Format identique au .exe Sanef Patch Manager pour réutiliser les workflows
existants côté SharePoint."""
existants côté SharePoint.
Si pct_confirmed=True (le serveur nécessitait une prévenance PCT et elle a
été confirmée par l'opérateur avant le patching), un suffixe " (Prévenance PCT ok)"
est ajouté pour signaler au responsable que l'amont a été géré."""
nom = (intervenant or "SecOps")
nom = nom[:1].upper() + nom[1:] if nom else "SecOps"
suffix = " (Prévenance PCT ok)" if pct_confirmed else ""
if msg_type == "debut":
return f"[SECOPS] : Intervenant({nom}) => Debut d'intervention sur {server_name}"
return f"[SECOPS] : Intervenant({nom}) => Debut d'intervention sur {server_name}{suffix}"
if msg_type == "reboot":
return f"[SECOPS] : Intervenant({nom}) => Reboot suite MAJ de {server_name}"
if msg_type == "fin":
return f"[SECOPS] : Intervenant({nom}) => Fin d'intervention sur {server_name}"
return f"[SECOPS] : Intervenant({nom}) => Fin d'intervention sur {server_name}{suffix}"
if msg_type == "annulation":
return f"[SECOPS] : Intervenant({nom}) => Annulation intervention sur {server_name}"
return f"[SECOPS] : Intervenant({nom}) => {msg_type} {server_name}"
@ -52,7 +58,8 @@ def format_message_text(server_name: str, msg_type: str, intervenant: str) -> st
def write_sharepoint_notification(sp_base: str, sp_route: str, msg_type: str,
server_name: str, intervenant: str,
dynamic_to_email: Optional[str] = None) -> Dict[str, Any]:
dynamic_to_email: Optional[str] = None,
pct_confirmed: bool = False) -> Dict[str, Any]:
"""Écrit un fichier .txt dans <sp_base>/<sp_route>/.
Format nom de fichier : <msg_type>_<server>_<YYYYMMDD_HHMMSS>.txt
@ -60,12 +67,15 @@ def write_sharepoint_notification(sp_base: str, sp_route: str, msg_type: str,
- Si dynamic_to_email : 1ère ligne `TO: <email>`, puis le message
- Sinon : juste le message texte plat
Si pct_confirmed=True, le message inclut le suffixe "(Prévenance PCT ok)".
Le sous-dossier est créé s'il n'existe pas.
Retourne {ok, path, detail} ou {ok: False, msg}."""
if not sp_base or not sp_route:
return {"ok": False, "msg": "sharepoint_notif_path ou sp_route manquant"}
body = format_message_text(server_name, msg_type, intervenant)
body = format_message_text(server_name, msg_type, intervenant,
pct_confirmed=pct_confirmed)
if not body:
return {"ok": False, "msg": f"msg_type inconnu: {msg_type}"}
@ -380,12 +390,16 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D
}
def send_notification(db, server_id: int, msg_type: str, intervenant: str) -> Dict[str, Any]:
def send_notification(db, server_id: int, msg_type: str, intervenant: str,
planning_row_id: Optional[int] = None) -> Dict[str, Any]:
"""Orchestration haut niveau : résout les canaux et envoie 1 notif par canal.
Mode 'sharepoint' : écrit un fichier .txt par canal (fan-out multi-recipient).
Mode 'webhook' : non implémenté tant qu'OAuth pas en place.
Si planning_row_id est fourni, on lit pct_required + pct_confirmed_at sur la ligne
pour ajouter le suffixe "(Prévenance PCT ok)" au message Teams le cas échéant.
Retourne {ok, server, results: list[ {channel_name, source, ok, detail/path} ]}.
"""
from .secrets_service import get_secret
@ -397,6 +411,15 @@ def send_notification(db, server_id: int, msg_type: str, intervenant: str) -> Di
server_name = db.execute(sqlt("SELECT hostname FROM servers WHERE id = :id"),
{"id": server_id}).scalar() or "unknown"
pct_confirmed_flag = False
if planning_row_id:
row = db.execute(sqlt("""
SELECT pct_required, pct_confirmed_at
FROM patch_planning_import_rows WHERE id = :id
"""), {"id": planning_row_id}).fetchone()
if row and row.pct_required and row.pct_confirmed_at:
pct_confirmed_flag = True
sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip()
results = []
@ -412,6 +435,7 @@ def send_notification(db, server_id: int, msg_type: str, intervenant: str) -> Di
sp_base=sp_base, sp_route=ch["sp_route"], msg_type=msg_type,
server_name=server_name, intervenant=intervenant,
dynamic_to_email=ch.get("dynamic_to_email"),
pct_confirmed=pct_confirmed_flag,
)
out["channel_name"] = ch["name"]
out["source"] = ch["source"]

View File

@ -84,8 +84,15 @@
</thead>
<tbody id="check-tbody">
{% for r in rows %}
<tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}">
<td class="p-1 font-mono">{{ r.asset_name }}</td>
<tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}"
data-pct-required="{{ '1' if r.pct_required else '0' }}"
data-pct-confirmed="{{ '1' if r.pct_confirmed_at else '0' }}">
<td class="p-1 font-mono">{{ r.asset_name }}{% if r.pct_required %}
<span class="badge {% if r.pct_confirmed_at %}badge-green{% else %}badge-orange{% endif %} ml-1 cell-pct-badge"
title="{% if r.pct_confirmed_at %}PCT confirmé le {{ r.pct_confirmed_at }}{% else %}Prévenance PCT requise — à confirmer avant le patch{% endif %}">
{% if r.pct_confirmed_at %}✅ PCT ok{% else %}⚠ Prév PCT à faire{% endif %}
</span>
{% endif %}</td>
<td class="p-1 font-mono">{{ r.hostname or '' }}</td>
<td class="p-1">{{ r.environnement or '' }}</td>
<td class="p-1">{{ r.domaine if r.domaine is defined else '' }}</td>
@ -741,6 +748,56 @@ function toggleDetails(){
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const targets = trs.filter(tr => tr._preData && tr._preData.ok);
if (!targets.length) { alert('Aucun serveur avec pre-capture OK'); return; }
// ─── Gate Prévenance PCT ─────────────────────────────────────
// Pour chaque serveur cible avec pct_required=1 et pct_confirmed=0,
// demander la confirmation que la PCT a été prévenue.
const pctPending = targets.filter(tr =>
tr.dataset.pctRequired === '1' && tr.dataset.pctConfirmed !== '1'
);
if (pctPending.length) {
const list = pctPending.map(tr =>
' • ' + (tr.querySelector('td:nth-child(2)').textContent.trim()
|| tr.querySelector('td:nth-child(1)').textContent.trim())
).join('\n');
const msg = '⚠ PRÉVENANCE PCT requise pour ' + pctPending.length + ' serveur(s) :\n\n'
+ list + '\n\n'
+ 'La PCT doit avoir été prévenue EN AMONT de cette intervention.\n'
+ 'Confirmer que la prévenance PCT a bien été faite pour ces serveurs ?';
if (!confirm(msg)) {
alert('Patch annulé. Préviens la PCT puis relance le step 3.');
return;
}
// Re-confirm pour éviter le clic réflexe
if (!confirm('Tu confirmes une 2e fois que la PCT est prévenue pour ces ' + pctPending.length + ' serveur(s) ? (cette confirmation est tracée en BDD)')) {
alert('Patch annulé.');
return;
}
// Marquer en BDD
const rowIds = pctPending.map(tr => tr.dataset.rowId).join(',');
try {
const fd = new FormData(); fd.append('row_ids', rowIds);
const r = await fetch('/patching/iexec/confirm-pct', {
method: 'POST', credentials: 'same-origin', body: fd,
});
const j = await r.json();
if (!j.ok) { alert('Échec enregistrement confirmation PCT: ' + (j.msg || '')); return; }
// Mettre à jour DOM : badges en vert, attribut pct_confirmed=1
pctPending.forEach(tr => {
tr.dataset.pctConfirmed = '1';
const badge = tr.querySelector('.cell-pct-badge');
if (badge) {
badge.classList.remove('badge-orange');
badge.classList.add('badge-green');
badge.textContent = '✅ PCT ok';
}
});
} catch (e) {
alert('Erreur réseau confirm-pct: ' + e); return;
}
}
// ─── Fin gate PCT ────────────────────────────────────────────
if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nSnapshot + pre-capture déjà faits.\nLog en temps réel dans le terminal.')) return;
if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return;
setBtnState(btnStep3, 'running'); setStepState('patch', 'running');

View File

@ -0,0 +1,25 @@
-- Migration : workflow Prévenance PCT (gate confirmation + tracker)
-- - patch_planning_import_rows.pct_required : copie au moment de l'import (auto-détecté
-- à partir du contenu des colonnes texte référent_technique / mode_operatoire / impacts).
-- - patch_planning_import_rows.pct_confirmed_at : timestamp de confirmation par l'opérateur
-- avant le démarrage patching.
-- - patch_planning_import_rows.pct_confirmed_by_user_id : qui a confirmé.
-- - Backfill : pour les rows existantes, init pct_required depuis servers.pct_required.
-- Idempotent.
ALTER TABLE public.patch_planning_import_rows
ADD COLUMN IF NOT EXISTS pct_required boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS pct_confirmed_at timestamptz,
ADD COLUMN IF NOT EXISTS pct_confirmed_by_user_id integer REFERENCES public.users(id) ON DELETE SET NULL;
-- Backfill : récupère pct_required du serveur lié pour les rows déjà importées
UPDATE public.patch_planning_import_rows r
SET pct_required = COALESCE(s.pct_required, false)
FROM public.servers s
WHERE r.server_id = s.id
AND r.pct_required = false
AND COALESCE(s.pct_required, false) = true;
CREATE INDEX IF NOT EXISTS idx_pp_rows_pct_pending
ON public.patch_planning_import_rows(pct_required)
WHERE pct_required = true AND pct_confirmed_at IS NULL;