From 29f6153370a884bd42f80bbf3595897874680d7b Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Thu, 7 May 2026 08:19:19 +0200 Subject: [PATCH] feat(pct): workflow prevenance PCT (auto-detection + gate confirmation + suffixe Teams) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/routers/planning_import.py | 54 ++++++++++++++++++++++++++- app/services/teams_service.py | 38 +++++++++++++++---- app/templates/patching_iexec.html | 61 ++++++++++++++++++++++++++++++- migrate_pct_workflow_20260507.sql | 25 +++++++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 migrate_pct_workflow_20260507.sql diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 7d0e737..0b58cf3 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -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. diff --git a/app/services/teams_service.py b/app/services/teams_service.py index e753c23..5eb96c1 100644 --- a/app/services/teams_service.py +++ b/app/services/teams_service.py @@ -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 //. Format nom de fichier : __.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: `, 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"] diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index a14bb06..c05726d 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -84,8 +84,15 @@ {% for r in rows %} - - {{ r.asset_name }} + + {{ r.asset_name }}{% if r.pct_required %} + + {% if r.pct_confirmed_at %}✅ PCT ok{% else %}⚠ Prév PCT à faire{% endif %} + + {% endif %} {{ r.hostname or '–' }} {{ r.environnement or '' }} {{ r.domaine if r.domaine is defined else '' }} @@ -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'); diff --git a/migrate_pct_workflow_20260507.sql b/migrate_pct_workflow_20260507.sql new file mode 100644 index 0000000..ef2829a --- /dev/null +++ b/migrate_pct_workflow_20260507.sql @@ -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;