diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 99f587f..8812394 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -922,7 +922,347 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)): 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'' + f'{srv}' + f'{app}' + f'{env}' + f'{dh}' + f'' + ) + + 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""" + + + + +
+ + + + + + + + + + +
+
SANEF — SecOps
+

Prévenance d'intervention PCT

+
+

Bonjour,

+

+ Dans le cadre des corrections de vulnérabilités du SI SANEF, une intervention est prévue sur + {target_label}. +

+ + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Nature de l'intervention :Mise à jour du serveur plus redémarrage
Type d'intervention :Correctif
Localisation :{localisations}
Équipement / service :{equip_service}
Plage horaire estimée :{plage_str}
+
+ + +

+ Détail des interventions ({n} serveur{'s' if n>1 else ''}) +

+ + + + + + + + {serv_table_rows} +
ServeurApplicationEnv.Date / Heure
+ + + + +
+ Impact : interruption d'environ 5 minutes par serveur au moment du redémarrage. +
+ + + + + + + + + + + + + + +
Moyen d'exploitation à prévoir :Aucun
Upgrade réalisé par :{intervenant_name or 'SecOps'}
Moyen d'exploitation prévu :Rollback en cas de problème
+ +

+ Cordialement,
+ L'équipe SecOps SANEF +

+
+ Mail généré automatiquement par PatchCenter. Pour toute question : équipe SecOps. +
+
+""" + + 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). Retourne (row_obj, error_response). error_response is None si OK.""" if not _can_import(perms): diff --git a/app/routers/settings.py b/app/routers/settings.py index 22f3fd4..5f0b902 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -78,6 +78,15 @@ SECTIONS = { ("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True), ("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_enabled", "Activer LDAP/AD (true/false)", False), ("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False), @@ -123,6 +132,7 @@ SECTION_ACCESS = { "itop": {"visible": ["admin"], "editable": ["admin"]}, "itop_contacts": {"visible": ["admin"], "editable": ["admin"]}, "ldap": {"visible": ["admin"], "editable": ["admin"]}, + "smtp": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]}, "security": {"visible": ["admin"], "editable": ["admin"]}, } diff --git a/app/services/mail_service.py b/app/services/mail_service.py new file mode 100644 index 0000000..659bc55 --- /dev/null +++ b/app/services/mail_service.py @@ -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"} diff --git a/app/templates/patching_import.html b/app/templates/patching_import.html index 1b4de2b..18d6340 100644 --- a/app/templates/patching_import.html +++ b/app/templates/patching_import.html @@ -155,6 +155,17 @@ + {% if can_import %} + + {% endif %} @@ -468,7 +479,132 @@ } 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 => '' + escapeHTML(c.name) + ' <' + escapeHTML(c.email) + '>').join(' ') + : '(aucun contact en CC — vérifie les responsables/référents en BDD)'; + 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); })(); {% endif %} + +{# Modal preview Prévenance PCT #} + + +
+
+
+
+
+
Prévenance PCT
+

Aperçu du mail avant envoi

+
+ +
+
+
Destinataire :
+
CC :
+
Objet :
+
Serveurs : ligne(s) sélectionnée(s)
+ +

Aperçu HTML du mail

+ +
+ +
+
+ {% endblock %}