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()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
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)
|
# 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
|
# Le fichier 2026 a 12 variantes d'en-têtes selon la semaine
|
||||||
# (ancien format S02-S06, nouveau format DTS S07+)
|
# (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"))
|
jour_d, jour_t = _coerce_date_or_text(rec.get("jour"))
|
||||||
heure_t = _coerce_time(rec.get("heure"))
|
heure_t = _coerce_time(rec.get("heure"))
|
||||||
heure_disp = _format_heure(rec.get("heure"))
|
heure_disp = _format_heure(rec.get("heure"))
|
||||||
|
pct_req = _detect_pct_required(rec)
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO patch_planning_import_rows (
|
INSERT INTO patch_planning_import_rows (
|
||||||
import_id, sheet_name, week_number, row_index,
|
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,
|
commentaire, base_de_donnees,
|
||||||
duree_coupure, jour, jour_text, heure, heure_t,
|
duree_coupure, jour, jour_text, heure, heure_t,
|
||||||
pb_espace_disque, date_patch_realise,
|
pb_espace_disque, date_patch_realise,
|
||||||
raw_data, server_id
|
raw_data, server_id, pct_required
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:imp, :sn, :wn, :ri,
|
:imp, :sn, :wn, :ri,
|
||||||
:an, :it, :en, :do, :os, :ov,
|
:an, :it, :en, :do, :os, :ov,
|
||||||
@ -531,7 +546,7 @@ async def import_upload(request: Request, db=Depends(get_db),
|
|||||||
:co, :bdd,
|
:co, :bdd,
|
||||||
:dc, :jr, :jt, :hr, :ht,
|
:dc, :jr, :jt, :hr, :ht,
|
||||||
:pb, :dpr,
|
:pb, :dpr,
|
||||||
:raw, :sid
|
:raw, :sid, :pctr
|
||||||
)
|
)
|
||||||
"""), {
|
"""), {
|
||||||
"imp": import_id, "sn": sheet_name, "wn": week_num, "ri": rec["row_index"],
|
"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")),
|
"dpr": _coerce_date(rec.get("date_patch_realise")),
|
||||||
"raw": json.dumps(rec.get("_raw") or {}, ensure_ascii=False, default=str),
|
"raw": json.dumps(rec.get("_raw") or {}, ensure_ascii=False, default=str),
|
||||||
"sid": sid,
|
"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
|
row_count += 1
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
UPDATE patch_planning_imports SET sheet_count=:s, row_count=:r WHERE id=:id
|
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,
|
SELECT r.id, r.asset_name, r.environnement, r.domaine, r.os, r.os_version,
|
||||||
r.intervenant,
|
r.intervenant,
|
||||||
r.is_eligible, r.server_id,
|
r.is_eligible, r.server_id,
|
||||||
|
r.pct_required, r.pct_confirmed_at,
|
||||||
s.hostname, vs.effective_excludes
|
s.hostname, vs.effective_excludes
|
||||||
FROM patch_planning_import_rows r
|
FROM patch_planning_import_rows r
|
||||||
LEFT JOIN servers s ON s.id = r.server_id
|
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)
|
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}")
|
@router.post("/patching/iexec/check/{row_id}")
|
||||||
async def iexec_check(request: Request, row_id: int, db=Depends(get_db)):
|
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.
|
"""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
|
# 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).
|
"""Formate le message texte plat (compatible workflow PA → Teams).
|
||||||
Format identique au .exe Sanef Patch Manager pour réutiliser les workflows
|
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 = (intervenant or "SecOps")
|
||||||
nom = nom[:1].upper() + nom[1:] if nom else "SecOps"
|
nom = nom[:1].upper() + nom[1:] if nom else "SecOps"
|
||||||
|
suffix = " (Prévenance PCT ok)" if pct_confirmed else ""
|
||||||
if msg_type == "debut":
|
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":
|
if msg_type == "reboot":
|
||||||
return f"[SECOPS] : Intervenant({nom}) => Reboot suite MAJ de {server_name}"
|
return f"[SECOPS] : Intervenant({nom}) => Reboot suite MAJ de {server_name}"
|
||||||
if msg_type == "fin":
|
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":
|
if msg_type == "annulation":
|
||||||
return f"[SECOPS] : Intervenant({nom}) => Annulation intervention sur {server_name}"
|
return f"[SECOPS] : Intervenant({nom}) => Annulation intervention sur {server_name}"
|
||||||
return f"[SECOPS] : Intervenant({nom}) => {msg_type} {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,
|
def write_sharepoint_notification(sp_base: str, sp_route: str, msg_type: str,
|
||||||
server_name: str, intervenant: 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>/.
|
"""Écrit un fichier .txt dans <sp_base>/<sp_route>/.
|
||||||
|
|
||||||
Format nom de fichier : <msg_type>_<server>_<YYYYMMDD_HHMMSS>.txt
|
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
|
- Si dynamic_to_email : 1ère ligne `TO: <email>`, puis le message
|
||||||
- Sinon : juste le message texte plat
|
- 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.
|
Le sous-dossier est créé s'il n'existe pas.
|
||||||
Retourne {ok, path, detail} ou {ok: False, msg}."""
|
Retourne {ok, path, detail} ou {ok: False, msg}."""
|
||||||
if not sp_base or not sp_route:
|
if not sp_base or not sp_route:
|
||||||
return {"ok": False, "msg": "sharepoint_notif_path ou sp_route manquant"}
|
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:
|
if not body:
|
||||||
return {"ok": False, "msg": f"msg_type inconnu: {msg_type}"}
|
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.
|
"""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 'sharepoint' : écrit un fichier .txt par canal (fan-out multi-recipient).
|
||||||
Mode 'webhook' : non implémenté tant qu'OAuth pas en place.
|
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} ]}.
|
Retourne {ok, server, results: list[ {channel_name, source, ok, detail/path} ]}.
|
||||||
"""
|
"""
|
||||||
from .secrets_service import get_secret
|
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"),
|
server_name = db.execute(sqlt("SELECT hostname FROM servers WHERE id = :id"),
|
||||||
{"id": server_id}).scalar() or "unknown"
|
{"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()
|
sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip()
|
||||||
|
|
||||||
results = []
|
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,
|
sp_base=sp_base, sp_route=ch["sp_route"], msg_type=msg_type,
|
||||||
server_name=server_name, intervenant=intervenant,
|
server_name=server_name, intervenant=intervenant,
|
||||||
dynamic_to_email=ch.get("dynamic_to_email"),
|
dynamic_to_email=ch.get("dynamic_to_email"),
|
||||||
|
pct_confirmed=pct_confirmed_flag,
|
||||||
)
|
)
|
||||||
out["channel_name"] = ch["name"]
|
out["channel_name"] = ch["name"]
|
||||||
out["source"] = ch["source"]
|
out["source"] = ch["source"]
|
||||||
|
|||||||
@ -84,8 +84,15 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="check-tbody">
|
<tbody id="check-tbody">
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}">
|
<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>
|
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 font-mono">{{ r.hostname or '–' }}</td>
|
||||||
<td class="p-1">{{ r.environnement or '' }}</td>
|
<td class="p-1">{{ r.environnement or '' }}</td>
|
||||||
<td class="p-1">{{ r.domaine if r.domaine is defined else '' }}</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 trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||||
const targets = trs.filter(tr => tr._preData && tr._preData.ok);
|
const targets = trs.filter(tr => tr._preData && tr._preData.ok);
|
||||||
if (!targets.length) { alert('Aucun serveur avec pre-capture OK'); return; }
|
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('⚠ 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;
|
if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return;
|
||||||
setBtnState(btnStep3, 'running'); setStepState('patch', 'running');
|
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