feat(pct): bouton Prevenance PCT + preview avant envoi + CC responsables/referents

- Service mail_service.py: send_html_mail via SMTP standard (host/port/user/pass/from/use_tls
  depuis Settings > SMTP). Gere SSL_465 et STARTTLS_587. Mode dry_run pour preview.
- Settings: nouvelle section 'smtp' avec smtp_host/port/user/pass/from/use_tls/pct_recipient
  (a configurer pour O365 SMTP submission)
- Router planning_import.py:
  * _build_pct_email(): construit subject + HTML pro/colore (header bleu degrade SANEF,
    cards avec border-left bleu/orange, tableau serveurs, footer)
  * Subject: 'Intervention sur <app>' si app uniforme, sinon liste des serveurs
  * Plage horaire = 20 min × N serveurs (formattee Hh MM)
  * 'Moyen d'exploitation prevu : Rollback en cas de probleme' ajoute en bas
  * _fetch_pct_cc_emails(): query distinct contacts depuis responsable_domaine_contact_id
    + referent_technique_contact_id + server_additional_referents
  * Endpoint POST /patching/import/pct-prevenance/preview retourne {subject, html, to, cc,
    smtp_configured, row_count} sans envoyer
  * Endpoint POST /patching/import/pct-prevenance/send envoie reellement, audit log,
    update pct_mail_sent_at sur les rows
- Template patching_import.html:
  * Bouton 'Prevenance PCT' (violet) a cote des autres actions
  * Modal preview avec iframe sandboxe pour le rendu HTML mail
  * Affiche destinataires, CC, objet, count serveurs
  * Warning rouge si SMTP non configure (envoi desactive, preview seulement)
  * 2 boutons: Annuler / Envoyer (avec confirmation)
This commit is contained in:
Pierre & Lumière 2026-05-07 21:44:02 +02:00
parent b2f8456b03
commit 00998e9320
4 changed files with 590 additions and 1 deletions

View File

@ -922,7 +922,347 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)):
return JSONResponse(result) return JSONResponse(result)
def _common_iexec_row_check(row_id: int, db, user, perms): # ────────────────────────────────────────────────────────────────────────
# 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."""
if not row_ids:
return []
placeholders = ",".join(str(i) for i in row_ids)
contact_ids_rows = db.execute(text(f"""
SELECT DISTINCT contact_id FROM (
SELECT s.responsable_domaine_contact_id AS 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})
) x WHERE contact_id IS NOT NULL
""")).fetchall()
cids = [r.contact_id for r in contact_ids_rows if r.contact_id]
if not cids:
return []
contacts = db.execute(text(f"""
SELECT id, name, email FROM contacts
WHERE id IN ({','.join(str(c) for c in cids)})
AND email IS NOT NULL AND email <> ''
AND is_active = true
ORDER BY name
""")).fetchall()
return [{"name": c.name, "email": c.email} for c in contacts]
@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/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). """Charge la row et applique les vérifs communes (éligible + Linux + hostname).
Retourne (row_obj, error_response). error_response is None si OK.""" Retourne (row_obj, error_response). error_response is None si OK."""
if not _can_import(perms): if not _can_import(perms):

View File

@ -78,6 +78,15 @@ SECTIONS = {
("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True), ("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True),
("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False), ("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False),
], ],
"smtp": [
("smtp_host", "Serveur SMTP (ex: vpdsismtp1.sanef.groupe)", False),
("smtp_port", "Port SMTP (25 / 465 / 587)", False),
("smtp_user", "User SMTP (vide si relay anonyme)", False),
("smtp_pass", "Password SMTP (vide si relay anonyme)", True),
("smtp_from", "From (ex: patchcenter@sanef.com)", False),
("smtp_use_tls", "Utiliser TLS/STARTTLS (true/false)", False),
("smtp_pct_recipient", "Destinataire prévenance PCT (ex: PCT.reims@sanef.com)", False),
],
"ldap": [ "ldap": [
("ldap_enabled", "Activer LDAP/AD (true/false)", False), ("ldap_enabled", "Activer LDAP/AD (true/false)", False),
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False), ("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
@ -123,6 +132,7 @@ SECTION_ACCESS = {
"itop": {"visible": ["admin"], "editable": ["admin"]}, "itop": {"visible": ["admin"], "editable": ["admin"]},
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]}, "itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
"ldap": {"visible": ["admin"], "editable": ["admin"]}, "ldap": {"visible": ["admin"], "editable": ["admin"]},
"smtp": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"security": {"visible": ["admin"], "editable": ["admin"]}, "security": {"visible": ["admin"], "editable": ["admin"]},
} }

View File

@ -0,0 +1,103 @@
"""Service mail simple — envoi HTML via SMTP (Office 365 ou autre relay).
Configuration via Settings > SMTP :
- smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from, smtp_use_tls
Si SMTP non configuré, send_html_mail retourne {ok:False, msg, html_preview}
pour que l'UI affiche au moins le rendu sans envoi.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Iterable
log = logging.getLogger("patchcenter.mail")
def _get(db, key, default=""):
try:
from .secrets_service import get_secret
return (get_secret(db, key) or default).strip()
except Exception:
return default
def get_smtp_config(db):
"""Lit la config SMTP depuis app_secrets. Retourne dict ou None si incomplet."""
host = _get(db, "smtp_host")
port_str = _get(db, "smtp_port", "587")
sender = _get(db, "smtp_from")
user = _get(db, "smtp_user")
pwd = _get(db, "smtp_pass")
use_tls = _get(db, "smtp_use_tls", "true").lower() in ("true", "1", "yes", "on")
if not host or not sender:
return None
try:
port = int(port_str or "587")
except ValueError:
port = 587
return {
"host": host, "port": port, "from": sender,
"user": user, "pass": pwd, "use_tls": use_tls,
}
def send_html_mail(db, to: Iterable[str], subject: str, html: str,
cc: Iterable[str] = None, dry_run: bool = False) -> dict:
"""Envoie un mail HTML.
- to : liste d'adresses (ou string seul)
- dry_run : si True, ne tente pas l'envoi (juste renvoie le rendu)
Retourne {ok, msg, sent_to, html_preview, subject_preview, error_kind}.
"""
if isinstance(to, str):
to = [to]
to = [a.strip() for a in to if a and a.strip()]
if not to:
return {"ok": False, "msg": "Destinataire(s) vide(s)"}
cfg = get_smtp_config(db)
if not cfg:
return {"ok": False, "msg": "Config SMTP incomplète (Settings > SMTP)",
"error_kind": "config_missing",
"html_preview": html, "subject_preview": subject, "sent_to": to}
if dry_run:
return {"ok": True, "msg": "Dry-run — pas d'envoi",
"html_preview": html, "subject_preview": subject, "sent_to": to}
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["from"]
msg["To"] = ", ".join(to)
if cc:
cc = [a.strip() for a in cc if a and a.strip()]
if cc:
msg["Cc"] = ", ".join(cc)
msg.attach(MIMEText(html, "html", "utf-8"))
recipients = list(to) + (list(cc) if cc else [])
try:
if cfg["port"] == 465:
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=30) as srv:
if cfg["user"] and cfg["pass"]:
srv.login(cfg["user"], cfg["pass"])
srv.sendmail(cfg["from"], recipients, msg.as_string())
else:
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=30) as srv:
srv.ehlo()
if cfg["use_tls"]:
srv.starttls()
srv.ehlo()
if cfg["user"] and cfg["pass"]:
srv.login(cfg["user"], cfg["pass"])
srv.sendmail(cfg["from"], recipients, msg.as_string())
log.info(f"Mail envoyé à {to} (subject={subject!r})")
return {"ok": True, "msg": f"Envoyé à {', '.join(to)}",
"sent_to": to, "subject_preview": subject}
except smtplib.SMTPAuthenticationError as e:
return {"ok": False, "msg": f"Auth SMTP refusée: {e}", "error_kind": "auth"}
except smtplib.SMTPException as e:
return {"ok": False, "msg": f"Erreur SMTP: {e}", "error_kind": "smtp"}
except Exception as e:
return {"ok": False, "msg": f"{type(e).__name__}: {e}", "error_kind": "other"}

View File

@ -155,6 +155,17 @@
<button id="btn-prepatch" class="btn-action btn-pre" title="Lancer le pré-patching (rows éligibles uniquement)"> <button id="btn-prepatch" class="btn-action btn-pre" title="Lancer le pré-patching (rows éligibles uniquement)">
▶ Pré-patching ▶ Pré-patching
</button> </button>
{% if can_import %}
<button id="btn-pct-mail" type="button" style="
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
border-radius: 6px; cursor: pointer; border: 1px solid #a78bfa;
background: rgba(167,139,250,.15); color: #a78bfa;
box-shadow: 0 0 8px #a78bfa; text-transform: uppercase;
letter-spacing: 0.03em;"
title="Envoie un mail à PCT.reims@sanef.com pour annoncer l'intervention sur les serveurs sélectionnés (preview avant envoi)">
📧 Prévenance PCT
</button>
{% endif %}
</div> </div>
<table class="w-full text-xs" id="sheet-table"> <table class="w-full text-xs" id="sheet-table">
<thead class="text-cyber-accent border-b border-cyber-border"> <thead class="text-cyber-accent border-b border-cyber-border">
@ -468,7 +479,132 @@
} }
window.location.href = '/patching/iexec?row_ids=' + ids.join(','); window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
}); });
// ─── Prévenance PCT — preview puis envoi ─────────────────────────
const btnPctMail = document.getElementById('btn-pct-mail');
if (btnPctMail) {
btnPctMail.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
// 1) preview
try {
const r = await fetch('/patching/import/pct-prevenance/preview', {
method: 'POST', headers: {'Content-Type':'application/json'},
credentials: 'same-origin',
body: JSON.stringify({row_ids: ids}),
});
const j = await r.json();
if (!j.ok) { alert('Erreur preview : ' + (j.msg || '')); return; }
showPctPreview(j, ids);
} catch (e) { alert('Erreur réseau : ' + e); }
});
}
function showPctPreview(data, ids) {
const modal = document.getElementById('pct-modal');
document.getElementById('pct-subject').textContent = data.subject;
document.getElementById('pct-to').textContent = data.to;
const ccList = (data.cc || []);
const ccHtml = ccList.length
? ccList.map(c => '<span class="badge badge-blue">' + escapeHTML(c.name) + ' &lt;' + escapeHTML(c.email) + '&gt;</span>').join(' ')
: '<span class="text-gray-500">(aucun contact en CC — vérifie les responsables/référents en BDD)</span>';
document.getElementById('pct-cc').innerHTML = ccHtml;
document.getElementById('pct-iframe').srcdoc = data.html;
document.getElementById('pct-smtp-warn').style.display = data.smtp_configured ? 'none' : '';
document.getElementById('pct-rowcount').textContent = data.row_count;
modal.classList.add('active');
const btnSend = document.getElementById('pct-btn-send');
btnSend.disabled = !data.smtp_configured;
btnSend.onclick = async () => {
if (!confirm('Envoyer ce mail à ' + data.to + ' (+ ' + ccList.length + ' en CC) ?')) return;
btnSend.disabled = true; btnSend.textContent = '⏳ Envoi…';
try {
const r = await fetch('/patching/import/pct-prevenance/send', {
method: 'POST', headers: {'Content-Type':'application/json'},
credentials: 'same-origin',
body: JSON.stringify({row_ids: ids}),
});
const j = await r.json();
if (j.ok) {
alert('✅ Mail envoyé.\n' + (j.msg || ''));
modal.classList.remove('active');
} else {
alert('❌ Échec envoi : ' + (j.msg || 'inconnu'));
}
} catch (e) { alert('Erreur réseau : ' + e); }
finally { btnSend.disabled = false; btnSend.textContent = '📤 Envoyer'; }
};
}
function closePctModal() {
document.getElementById('pct-modal').classList.remove('active');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closePctModal();
});
document.getElementById('pct-btn-cancel')?.addEventListener('click', closePctModal);
document.getElementById('pct-modal-bg')?.addEventListener('click', closePctModal);
})(); })();
</script> </script>
{% endif %} {% endif %}
{# Modal preview Prévenance PCT #}
<style>
#pct-modal { position:fixed; inset:0; z-index:9999; display:none; align-items:center; justify-content:center; }
#pct-modal.active { display:flex; }
#pct-modal-bg { position:absolute; inset:0; background:rgba(10,14,23,0.85); backdrop-filter:blur(2px); }
.pct-dialog {
position:relative; max-width:920px; width:95%; max-height:90vh;
background:#0f172a; border:1px solid #334155; border-radius:8px;
box-shadow: 0 0 40px rgba(167,139,250,0.4);
display:flex; flex-direction:column;
}
.pct-header { padding:14px 20px; border-bottom:1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
.pct-body { padding:14px 20px; overflow-y:auto; flex:1; }
.pct-footer { padding:12px 20px; border-top:1px solid #334155; display:flex; gap:10px; justify-content:flex-end; }
.pct-meta-row { display:flex; gap:10px; padding:6px 0; font-size:0.85rem; }
.pct-meta-row .lbl { color:#9ca3af; min-width:90px; }
.pct-meta-row .val { color:#e5e7eb; flex:1; word-break:break-all; }
.pct-meta-row .val .badge { display:inline-block; margin:2px 4px 2px 0; padding:2px 8px; border-radius:4px; font-size:11px; }
.pct-meta-row .val .badge-blue { background:rgba(59,130,246,0.15); color:#60a5fa; border:1px solid #3b82f6; }
#pct-iframe { width:100%; height:60vh; border:1px solid #334155; border-radius:4px; background:#fff; }
.pct-btn {
padding:8px 16px; font-size:0.85rem; font-weight:700; border-radius:6px;
cursor:pointer; border:1px solid; text-transform:uppercase; letter-spacing:0.03em;
}
.pct-btn-cancel { color:#9ca3af; border-color:#9ca3af; background:rgba(156,163,175,0.1); }
.pct-btn-cancel:hover { background:rgba(156,163,175,0.25); }
.pct-btn-send { color:#22c55e; border-color:#22c55e; background:rgba(34,197,94,0.15); }
.pct-btn-send:hover { background:rgba(34,197,94,0.30); }
.pct-btn-send:disabled { opacity:0.4; cursor:not-allowed; }
</style>
<div id="pct-modal">
<div id="pct-modal-bg"></div>
<div class="pct-dialog">
<div class="pct-header">
<div>
<div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#a78bfa;">Prévenance PCT</div>
<h3 style="margin:4px 0 0;color:#e5e7eb;font-size:1.1rem;">Aperçu du mail avant envoi</h3>
</div>
<button onclick="document.getElementById('pct-modal').classList.remove('active')"
style="background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:1.4rem;">✕</button>
</div>
<div class="pct-body">
<div class="pct-meta-row"><span class="lbl">Destinataire :</span><span class="val" id="pct-to"></span></div>
<div class="pct-meta-row"><span class="lbl">CC :</span><span class="val" id="pct-cc"></span></div>
<div class="pct-meta-row"><span class="lbl">Objet :</span><span class="val" id="pct-subject" style="font-weight:600;"></span></div>
<div class="pct-meta-row"><span class="lbl">Serveurs :</span><span class="val"><span id="pct-rowcount"></span> ligne(s) sélectionnée(s)</span></div>
<div id="pct-smtp-warn" style="margin:8px 0;padding:8px 12px;background:rgba(239,68,68,0.15);border-left:3px solid #ef4444;border-radius:4px;color:#fca5a5;font-size:0.85rem;display:none;">
<strong>SMTP non configuré</strong> dans Settings > SMTP. L'envoi est désactivé. Tu peux relire la preview ; configure SMTP plus tard pour activer l'envoi.
</div>
<h4 style="margin:14px 0 8px;color:#a78bfa;font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">Aperçu HTML du mail</h4>
<iframe id="pct-iframe" sandbox=""></iframe>
</div>
<div class="pct-footer">
<button id="pct-btn-cancel" class="pct-btn pct-btn-cancel">Annuler</button>
<button id="pct-btn-send" class="pct-btn pct-btn-send">📤 Envoyer</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}