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:
parent
060af01db9
commit
29f6153370
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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');
|
||||
|
||||
25
migrate_pct_workflow_20260507.sql
Normal file
25
migrate_pct_workflow_20260507.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user