diff --git a/app/routers/settings.py b/app/routers/settings.py index e55376a..1ee04e1 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -69,13 +69,14 @@ SECTIONS = { ("splunk_verify_ssl", "Verifier SSL (true/false)", False), ], "teams": [ - ("teams_webhook_url", "Webhook URL (canal)", False), - ("teams_sp_site_url", "SharePoint Site URL", False), - ("teams_sp_library", "SharePoint Library", False), - ("teams_sp_folder", "SharePoint Folder", False), - ("teams_sp_client_id", "App Client ID", False), - ("teams_sp_client_secret", "App Client Secret", True), - ("teams_sp_tenant_id", "Tenant ID", False), + ("sharepoint_notif_path", "Chemin local OneDrive sync (ex: C:\\Users\\xxx\\sanefgroupe...\\notifications)", False), + ("teams_webhook_url", "Webhook URL globale (legacy, futur OAuth)", False), + ("teams_sp_site_url", "SharePoint Site URL (futur webhook)", False), + ("teams_sp_library", "SharePoint Library (futur webhook)", False), + ("teams_sp_folder", "SharePoint Folder (futur webhook)", False), + ("teams_sp_client_id", "App Client ID (futur webhook OAuth)", False), + ("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True), + ("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False), ], "ldap": [ ("ldap_enabled", "Activer LDAP/AD (true/false)", False), @@ -135,9 +136,25 @@ def _build_context(db, user, saved=None): vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall() allowed_nets = db.execute(text("SELECT * FROM allowed_networks ORDER BY cidr")).fetchall() teams_channels = db.execute(text(""" - SELECT id, name, webhook_url, description, is_active, is_default, created_at + SELECT id, name, webhook_url, description, is_active, is_default, + sp_route, mode, is_reboot_channel, is_dynamic_dm, created_at FROM teams_channels - ORDER BY is_default DESC, name + ORDER BY is_default DESC, is_reboot_channel DESC, name + """)).fetchall() + teams_rules = db.execute(text(""" + SELECT r.id, r.priority, r.name, + r.match_responsable_contact_id, r.match_application_domain, + r.match_env_in, r.match_msg_type_in, r.match_hostname_pattern, + r.channel_id, r.is_active, r.created_at, + tc.name AS channel_name, + c.name AS responsable_name + FROM teams_channel_rules r + LEFT JOIN teams_channels tc ON tc.id = r.channel_id + LEFT JOIN contacts c ON c.id = r.match_responsable_contact_id + ORDER BY r.priority ASC, r.id ASC + """)).fetchall() + contacts_list = db.execute(text(""" + SELECT id, name, teams_upn FROM contacts WHERE is_active = true ORDER BY name """)).fetchall() server_clusters = db.execute(text(""" SELECT c.id, c.name, c.description, c.reboot_strategy, c.is_active, c.created_at, @@ -159,6 +176,8 @@ def _build_context(db, user, saved=None): "q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked, "vcenters": vcenters, "saved": saved, "teams_channels": teams_channels, + "teams_rules": teams_rules, + "contacts_list": contacts_list, "server_clusters": server_clusters, "visible": visible, "editable": editable, } @@ -264,24 +283,36 @@ async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)): @router.post("/settings/teams-channel/add", response_class=HTMLResponse) async def teams_channel_add(request: Request, db=Depends(get_db), tc_name: str = Form(...), - tc_webhook_url: str = Form(...), + tc_mode: str = Form("sharepoint"), + tc_sp_route: str = Form(""), + tc_webhook_url: str = Form(""), tc_description: str = Form(""), - tc_is_default: str = Form("")): + tc_is_default: str = Form(""), + tc_is_reboot_channel: str = Form(""), + tc_is_dynamic_dm: str = Form("")): user = get_current_user(request) if not user: return RedirectResponse(url="/login") perms = get_user_perms(db, user) if not can_edit(perms, "settings"): return RedirectResponse(url="/settings") + mode = tc_mode if tc_mode in ("sharepoint", "webhook") else "sharepoint" is_def = bool(tc_is_default) + is_reboot = bool(tc_is_reboot_channel) + is_dyn_dm = bool(tc_is_dynamic_dm) if is_def: - # un seul canal par défaut db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=true")) + if is_reboot: + db.execute(text("UPDATE teams_channels SET is_reboot_channel=false WHERE is_reboot_channel=true")) db.execute(text(""" - INSERT INTO teams_channels (name, webhook_url, description, is_default) - VALUES (:n, :u, :d, :df) - """), {"n": tc_name.strip(), "u": tc_webhook_url.strip(), - "d": (tc_description or None), "df": is_def}) + INSERT INTO teams_channels (name, mode, sp_route, webhook_url, description, + is_default, is_reboot_channel, is_dynamic_dm) + VALUES (:n, :m, :sp, :u, :d, :df, :rb, :dyn) + """), {"n": tc_name.strip(), "m": mode, + "sp": (tc_sp_route.strip() or None), + "u": (tc_webhook_url.strip() or None), + "d": (tc_description or None), + "df": is_def, "rb": is_reboot, "dyn": is_dyn_dm}) db.commit() ctx = _build_context(db, user, saved="teams") ctx["request"] = request @@ -291,27 +322,43 @@ async def teams_channel_add(request: Request, db=Depends(get_db), @router.post("/settings/teams-channel/{tc_id}/edit", response_class=HTMLResponse) async def teams_channel_edit(request: Request, tc_id: int, db=Depends(get_db), tc_name: str = Form(...), - tc_webhook_url: str = Form(...), + tc_mode: str = Form("sharepoint"), + tc_sp_route: str = Form(""), + tc_webhook_url: str = Form(""), tc_description: str = Form(""), tc_is_active: str = Form(""), - tc_is_default: str = Form("")): + tc_is_default: str = Form(""), + tc_is_reboot_channel: str = Form(""), + tc_is_dynamic_dm: str = Form("")): user = get_current_user(request) if not user: return RedirectResponse(url="/login") perms = get_user_perms(db, user) if not can_edit(perms, "settings"): return RedirectResponse(url="/settings") + mode = tc_mode if tc_mode in ("sharepoint", "webhook") else "sharepoint" is_def = bool(tc_is_default) is_act = bool(tc_is_active) + is_reboot = bool(tc_is_reboot_channel) + is_dyn_dm = bool(tc_is_dynamic_dm) if is_def: db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=true AND id<>:id"), {"id": tc_id}) + if is_reboot: + db.execute(text("UPDATE teams_channels SET is_reboot_channel=false WHERE is_reboot_channel=true AND id<>:id"), + {"id": tc_id}) db.execute(text(""" UPDATE teams_channels - SET name=:n, webhook_url=:u, description=:d, is_default=:df, is_active=:ia + SET name=:n, mode=:m, sp_route=:sp, webhook_url=:u, description=:d, + is_default=:df, is_active=:ia, + is_reboot_channel=:rb, is_dynamic_dm=:dyn WHERE id=:id - """), {"n": tc_name.strip(), "u": tc_webhook_url.strip(), - "d": (tc_description or None), "df": is_def, "ia": is_act, "id": tc_id}) + """), {"n": tc_name.strip(), "m": mode, + "sp": (tc_sp_route.strip() or None), + "u": (tc_webhook_url.strip() or None), + "d": (tc_description or None), + "df": is_def, "ia": is_act, + "rb": is_reboot, "dyn": is_dyn_dm, "id": tc_id}) db.commit() ctx = _build_context(db, user, saved="teams") ctx["request"] = request @@ -335,7 +382,9 @@ async def teams_channel_delete(request: Request, tc_id: int, db=Depends(get_db)) @router.post("/settings/teams-channel/{tc_id}/test") async def teams_channel_test(request: Request, tc_id: int, db=Depends(get_db)): - """Envoie un message test sur le webhook pour valider la config.""" + """Test du canal selon son mode : + - mode='sharepoint' : écrit un fichier de test dans // + - mode='webhook' : POST direct sur webhook_url (Adaptive Card)""" from fastapi.responses import JSONResponse user = get_current_user(request) if not user: @@ -343,18 +392,172 @@ async def teams_channel_test(request: Request, tc_id: int, db=Depends(get_db)): perms = get_user_perms(db, user) if not can_edit(perms, "settings"): return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) - row = db.execute(text("SELECT name, webhook_url FROM teams_channels WHERE id=:id"), - {"id": tc_id}).fetchone() + row = db.execute(text(""" + SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm + FROM teams_channels WHERE id=:id + """), {"id": tc_id}).fetchone() if not row: return JSONResponse({"ok": False, "msg": "Canal introuvable"}, status_code=404) + sender = user.get("username") or "PatchCenter" try: - from ..services.teams_service import send_test_message - result = send_test_message(row.webhook_url, row.name, user.get("username") or "PatchCenter") - return JSONResponse(result) + if row.mode == "sharepoint": + from ..services.teams_service import write_sharepoint_notification + sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip() + if not sp_base: + return JSONResponse({"ok": False, + "msg": "sharepoint_notif_path non configuré (Settings > Teams > Notifications)"}, + status_code=400) + if not row.sp_route: + return JSONResponse({"ok": False, "msg": "sp_route non défini sur ce canal"}, + status_code=400) + dyn_email = None + if row.is_dynamic_dm: + # Test mode : on met l'email du user lui-même (s'il est connu) + u_row = db.execute(text(""" + SELECT email FROM users WHERE username=:u OR email=:u LIMIT 1 + """), {"u": sender}).fetchone() + dyn_email = (u_row.email if u_row else "test.user@example.com") + result = write_sharepoint_notification( + sp_base=sp_base, sp_route=row.sp_route, msg_type="debut", + server_name=f"TEST-PATCHCENTER-{row.name}", intervenant=sender, + dynamic_to_email=dyn_email, + ) + return JSONResponse(result) + elif row.mode == "webhook": + if not row.webhook_url: + return JSONResponse({"ok": False, "msg": "webhook_url non défini"}, status_code=400) + from ..services.teams_service import send_test_message + result = send_test_message(row.webhook_url, row.name, sender) + return JSONResponse(result) + else: + return JSONResponse({"ok": False, "msg": f"Mode inconnu: {row.mode}"}, status_code=400) except Exception as e: return JSONResponse({"ok": False, "msg": f"Erreur: {e}"}, status_code=500) +# --- Teams channel rules CRUD --- + +def _parse_csv_text_array(s: str): + """'a, b ,c' -> ['a','b','c'] ; vide -> None.""" + if not s: + return None + items = [p.strip() for p in s.split(",")] + items = [p for p in items if p] + return items or None + + +@router.post("/settings/teams-rule/add", response_class=HTMLResponse) +async def teams_rule_add(request: Request, db=Depends(get_db), + tr_priority: int = Form(100), + tr_name: str = Form(...), + tr_channel_id: int = Form(...), + tr_match_responsable_contact_id: str = Form(""), + tr_match_application_domain: str = Form(""), + tr_match_env_in: str = Form(""), + tr_match_msg_type_in: str = Form(""), + tr_match_hostname_pattern: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/settings") + resp_id = int(tr_match_responsable_contact_id) if tr_match_responsable_contact_id.strip() else None + db.execute(text(""" + INSERT INTO teams_channel_rules + (priority, name, channel_id, + match_responsable_contact_id, match_application_domain, + match_env_in, match_msg_type_in, match_hostname_pattern) + VALUES (:p, :n, :ch, :rc, :dom, :env, :mt, :host) + """), { + "p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id, + "rc": resp_id, + "dom": (tr_match_application_domain.strip() or None), + "env": _parse_csv_text_array(tr_match_env_in), + "mt": _parse_csv_text_array(tr_match_msg_type_in), + "host": (tr_match_hostname_pattern.strip() or None), + }) + db.commit() + ctx = _build_context(db, user, saved="teams") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/teams-rule/{tr_id}/edit", response_class=HTMLResponse) +async def teams_rule_edit(request: Request, tr_id: int, db=Depends(get_db), + tr_priority: int = Form(100), + tr_name: str = Form(...), + tr_channel_id: int = Form(...), + tr_match_responsable_contact_id: str = Form(""), + tr_match_application_domain: str = Form(""), + tr_match_env_in: str = Form(""), + tr_match_msg_type_in: str = Form(""), + tr_match_hostname_pattern: str = Form(""), + tr_is_active: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/settings") + resp_id = int(tr_match_responsable_contact_id) if tr_match_responsable_contact_id.strip() else None + db.execute(text(""" + UPDATE teams_channel_rules + SET priority=:p, name=:n, channel_id=:ch, + match_responsable_contact_id=:rc, + match_application_domain=:dom, + match_env_in=:env, match_msg_type_in=:mt, + match_hostname_pattern=:host, + is_active=:ia + WHERE id=:id + """), { + "p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id, + "rc": resp_id, + "dom": (tr_match_application_domain.strip() or None), + "env": _parse_csv_text_array(tr_match_env_in), + "mt": _parse_csv_text_array(tr_match_msg_type_in), + "host": (tr_match_hostname_pattern.strip() or None), + "ia": bool(tr_is_active), "id": tr_id, + }) + db.commit() + ctx = _build_context(db, user, saved="teams") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/teams-rule/{tr_id}/delete", response_class=HTMLResponse) +async def teams_rule_delete(request: Request, tr_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/settings") + db.execute(text("DELETE FROM teams_channel_rules WHERE id=:id"), {"id": tr_id}) + db.commit() + ctx = _build_context(db, user, saved="teams") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/teams-rule/test") +async def teams_rule_test(request: Request, db=Depends(get_db), + server_id: int = Form(...), + msg_type: str = Form("debut")): + """Teste la résolution sans envoyer : retourne quel canal serait choisi pour + (server_id, msg_type) et pourquoi (source de la décision).""" + from fastapi.responses import JSONResponse + 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, "settings"): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + from ..services.teams_service import resolve_channel_for_server + result = resolve_channel_for_server(db, server_id, msg_type) + return JSONResponse(result) + + # --- Server clusters CRUD --- @router.post("/settings/server-cluster/add", response_class=HTMLResponse) diff --git a/app/services/teams_service.py b/app/services/teams_service.py index 46d4f83..dce9127 100644 --- a/app/services/teams_service.py +++ b/app/services/teams_service.py @@ -1,17 +1,26 @@ -"""Service Microsoft Teams — envoi de messages via Incoming Webhook. +"""Service Microsoft Teams — deux modes d'envoi : -Webhook = simple, pas d'auth Azure AD requise. -Format : Adaptive Card (titre + corps) ou simple "text" en fallback. +1. **mode='sharepoint'** (recommandé pour SANEF) — écriture d'un fichier .txt dans un + sous-dossier OneDrive synchronisé. Un Workflow Power Automate côté SharePoint + surveille le dossier et poste sur Teams. Aucun OAuth requis. Format identique au + .exe Sanef Patch Manager pour réutiliser les Workflows existants. -À noter : Teams a déprécié les "Office 365 Connectors" (anciens webhooks -chans Teams). Les nouveaux webhooks utilisent "Workflows" (Power Automate) -qui acceptent un payload Adaptive Card. Ce service envoie un payload -compatible avec les deux. +2. **mode='webhook'** (futur, OAuth requis sur tenant SANEF) — POST direct sur + l'URL Power Automate Direct API avec un Bearer token Entra ID. + +Le routage serveur → canal/conversation est géré par la table `teams_channel_rules` +(rules-based) et résolu par `resolve_channel_for_server`. + +Mode 'sharepoint' avec is_dynamic_dm=true : le fichier est préfixé par une ligne +`TO: ` que le Workflow PA lit pour poster dynamiquement à un destinataire, +sans avoir à créer un workflow par responsable. """ import json import logging +import os +import threading from datetime import datetime -from typing import Dict, Any +from typing import Dict, Any, Optional, List import requests @@ -20,6 +29,84 @@ log = logging.getLogger("patchcenter.teams") HTTP_TIMEOUT = 10 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: + """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.""" + nom = (intervenant or "SecOps") + nom = nom[:1].upper() + nom[1:] if nom else "SecOps" + if msg_type == "debut": + return f"[SECOPS] : Intervenant({nom}) => Debut d'intervention sur {server_name}" + 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}" + if msg_type == "annulation": + return f"[SECOPS] : Intervenant({nom}) => Annulation intervention sur {server_name}" + return f"[SECOPS] : Intervenant({nom}) => {msg_type} {server_name}" + + +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]: + """Écrit un fichier .txt dans //. + + Format nom de fichier : __.txt + Format contenu : + - Si dynamic_to_email : 1ère ligne `TO: `, puis le message + - Sinon : juste le message texte plat + + 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) + if not body: + return {"ok": False, "msg": f"msg_type inconnu: {msg_type}"} + + if dynamic_to_email: + content = f"TO: {dynamic_to_email}\n{body}\n" + else: + content = body + "\n" + + try: + notif_dir = os.path.join(sp_base, sp_route) + os.makedirs(notif_dir, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + # Nettoyage hostname pour nom fichier + safe_host = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" + for c in (server_name or "unknown"))[:80] + fname = f"{msg_type}_{safe_host}_{ts}.txt" + fpath = os.path.join(notif_dir, fname) + with open(fpath, "w", encoding="utf-8") as f: + f.write(content) + log.info("Teams SP notif écrite: %s", fpath) + return {"ok": True, "path": fpath, "detail": "Fichier écrit, en attente sync OneDrive"} + except Exception as e: + log.error("Échec écriture SP notif: %s", e) + return {"ok": False, "msg": f"Erreur écriture: {e}"} + + +def write_sharepoint_notification_async(sp_base: str, sp_route: str, msg_type: str, + server_name: str, intervenant: str, + dynamic_to_email: Optional[str] = None) -> None: + """Variante non-bloquante : lance write_sharepoint_notification dans un thread.""" + threading.Thread( + target=write_sharepoint_notification, + args=(sp_base, sp_route, msg_type, server_name, intervenant, dynamic_to_email), + daemon=True, + ).start() + + +# ─────────────────────────────────────────────────────────────── +# MODE WEBHOOK — POST direct (futur, OAuth requis) +# ─────────────────────────────────────────────────────────────── + def _proxies(): if PROXY_URL: @@ -76,48 +163,184 @@ def _adaptive_card(title: str, body_lines: list, color: str = "good") -> Dict[st } -def resolve_channel_for_server(db, server_id: int) -> Dict[str, Any]: - """Résout le canal Teams à utiliser pour un serveur donné. - Priorité : - 1. servers.teams_channel_id (override par serveur) - 2. applications.teams_channel_id (canal de l'app rattachée) - 3. teams_channels.is_default = true (canal global) - Retourne {ok, channel_id, name, webhook_url, source} ou {ok: False, msg}.""" +def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> Dict[str, Any]: + """Résout le canal Teams à utiliser pour un serveur, selon le msg_type. + + Algorithme : + 1. Si msg_type='reboot' → canal flaggé `is_reboot_channel=true` (priorité absolue) + 2. servers.teams_channel_id (override explicite) + 3. teams_channel_rules (ordre `priority ASC`, première règle qui matche + toutes les conditions actives) + 4. teams_channels.is_default=true (fallback global) + 5. Sinon → pas de notif + + Si le canal résolu a `is_dynamic_dm=true`, on récupère le `teams_upn` du + `responsable_domaine_contact` du serveur pour préfixer le fichier `TO: `. + + Retourne { + ok: bool, + channel_id, name, mode, sp_route, webhook_url, is_dynamic_dm, + dynamic_to_email (str | None), + source ('reboot'|'server_override'|'rule:'|'default'), + msg (si ok=False), + } + """ from sqlalchemy import text as sqlt if not server_id: return {"ok": False, "msg": "server_id manquant"} - # 1) Override par serveur - row = db.execute(sqlt(""" - SELECT tc.id, tc.name, tc.webhook_url, 'server' AS source + + def _channel_row(channel_id): + return db.execute(sqlt(""" + SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm + FROM teams_channels + WHERE id = :id AND is_active = true + """), {"id": channel_id}).fetchone() + + def _resolve_dynamic_to_email(server_row): + """Récupère teams_upn du responsable_domaine pour DM dynamique.""" + if not server_row.responsable_domaine_contact_id: + return None + c = db.execute(sqlt(""" + SELECT teams_upn FROM contacts WHERE id = :id + """), {"id": server_row.responsable_domaine_contact_id}).fetchone() + return c.teams_upn if c and c.teams_upn else None + + # Récupère infos serveur (utilisé partout) + s = db.execute(sqlt(""" + SELECT s.id, s.hostname, s.environnement, s.application_id, + s.responsable_domaine_contact_id, s.referent_technique_contact_id, + s.teams_channel_id AS server_channel_id, + a.domain AS app_domain, a.teams_channel_id AS app_channel_id FROM servers s - JOIN teams_channels tc ON tc.id = s.teams_channel_id - WHERE s.id = :sid AND tc.is_active = true + LEFT JOIN applications a ON a.id = s.application_id + WHERE s.id = :sid """), {"sid": server_id}).fetchone() - if row: - return {"ok": True, "channel_id": row.id, "name": row.name, - "webhook_url": row.webhook_url, "source": row.source} - # 2) Canal de l'application - row = db.execute(sqlt(""" - SELECT tc.id, tc.name, tc.webhook_url, 'application' AS source - FROM servers s - JOIN applications a ON a.id = s.application_id - JOIN teams_channels tc ON tc.id = a.teams_channel_id - WHERE s.id = :sid AND tc.is_active = true - """), {"sid": server_id}).fetchone() - if row: - return {"ok": True, "channel_id": row.id, "name": row.name, - "webhook_url": row.webhook_url, "source": row.source} - # 3) Canal défaut global - row = db.execute(sqlt(""" - SELECT id, name, webhook_url, 'default' AS source + if not s: + return {"ok": False, "msg": f"Serveur id={server_id} introuvable"} + + def _build_result(ch, source): + if not ch: + return None + dyn_email = _resolve_dynamic_to_email(s) if ch.is_dynamic_dm else None + return { + "ok": True, + "channel_id": ch.id, "name": ch.name, + "mode": ch.mode, "sp_route": ch.sp_route, + "webhook_url": ch.webhook_url, + "is_dynamic_dm": ch.is_dynamic_dm, + "dynamic_to_email": dyn_email, + "source": source, + } + + # 1) Reboot → canal flaggé reboot (priorité absolue) + if msg_type == "reboot": + ch = db.execute(sqlt(""" + SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm + FROM teams_channels + WHERE is_reboot_channel = true AND is_active = true + ORDER BY id LIMIT 1 + """)).fetchone() + if ch: + return _build_result(ch, "reboot") + # Sinon on continue, peut-être un canal default routera + + # 2) Override par serveur + if s.server_channel_id: + ch = _channel_row(s.server_channel_id) + if ch: + return _build_result(ch, "server_override") + + # 3) Règles (ordre priority asc) + rules = db.execute(sqlt(""" + SELECT id, name, priority, channel_id, + match_responsable_contact_id, match_application_domain, + match_env_in, match_msg_type_in, match_hostname_pattern + FROM teams_channel_rules + WHERE is_active = true + ORDER BY priority ASC, id ASC + """)).fetchall() + + server_env = (s.environnement or "").strip() + server_dom = (s.app_domain or "").strip() + server_host = (s.hostname or "").lower() + + for r in rules: + if r.match_msg_type_in and msg_type not in r.match_msg_type_in: + continue + if r.match_responsable_contact_id and \ + s.responsable_domaine_contact_id != r.match_responsable_contact_id: + continue + if r.match_application_domain: + md = r.match_application_domain.strip().lower() + if not server_dom or md not in server_dom.lower(): + continue + if r.match_env_in: + envs_lower = [e.lower() for e in r.match_env_in] + if not server_env or server_env.lower() not in envs_lower: + continue + if r.match_hostname_pattern: + pat = r.match_hostname_pattern.strip().lower() + if pat and pat not in server_host: + continue + # Tout matche + ch = _channel_row(r.channel_id) + if ch: + return _build_result(ch, f"rule:{r.name}") + + # 4) Default global + ch = db.execute(sqlt(""" + SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm FROM teams_channels WHERE is_default = true AND is_active = true ORDER BY id LIMIT 1 """)).fetchone() - if row: - return {"ok": True, "channel_id": row.id, "name": row.name, - "webhook_url": row.webhook_url, "source": row.source} - return {"ok": False, "msg": "Aucun canal Teams configuré (ni serveur, ni app, ni défaut)"} + if ch: + return _build_result(ch, "default") + + return {"ok": False, "msg": "Aucun canal Teams résolu (aucune règle ne matche, pas de canal défaut)"} + + +def send_notification(db, server_id: int, msg_type: str, intervenant: str) -> Dict[str, Any]: + """Orchestration haut niveau : résout le canal puis envoie la notif. + + Pour mode='sharepoint' : écrit le fichier dans le bon sous-dossier. + Pour mode='webhook' : POST sur webhook_url (non implémenté tant qu'OAuth pas en place). + + Retourne le résultat de l'envoi (ok, detail, …) enrichi du contexte de résolution. + """ + from .secrets_service import get_secret + res = resolve_channel_for_server(db, server_id, msg_type) + if not res.get("ok"): + return res + + server_name = db.execute(__import__("sqlalchemy").text( + "SELECT hostname FROM servers WHERE id = :id" + ), {"id": server_id}).scalar() or "unknown" + + if res["mode"] == "sharepoint": + sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip() + if not sp_base: + return {"ok": False, "msg": "sharepoint_notif_path non configuré dans Settings", + "channel": res["name"]} + out = write_sharepoint_notification( + sp_base=sp_base, sp_route=res["sp_route"], msg_type=msg_type, + server_name=server_name, intervenant=intervenant, + dynamic_to_email=res.get("dynamic_to_email"), + ) + out["channel"] = res["name"] + out["source"] = res["source"] + return out + + if res["mode"] == "webhook": + return {"ok": False, "msg": "Mode webhook non implémenté (OAuth Entra ID requis)", + "channel": res["name"]} + + return {"ok": False, "msg": f"Mode inconnu: {res['mode']}"} + + +# ─────────────────────────────────────────────────────────────── +# Compat : helper webhook (à conserver pour le bouton "Tester" dans Settings) +# ─────────────────────────────────────────────────────────────── def send_test_message(webhook_url: str, channel_name: str, sender: str) -> Dict[str, Any]: diff --git a/app/templates/settings.html b/app/templates/settings.html index c0d7ae8..69126fb 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -286,15 +286,17 @@
Canaux Microsoft Teams Notifications - {{ teams_channels|selectattr('is_active')|list|length }} actif(s){% if teams_channels|selectattr('is_default')|list|length %} · 1 défaut{% endif %} + {{ teams_channels|selectattr('is_active')|list|length }} actif(s) · {{ teams_rules|selectattr('is_active')|list|length }} règle(s){% if teams_channels|selectattr('is_default')|list|length %} · 1 défaut{% endif %}{% if teams_channels|selectattr('is_reboot_channel')|list|length %} · 1 reboot{% endif %}
-
+

- Configurez ici les webhooks Teams (Workflows / Incoming) utilisés pour - annoncer les interventions de patching. Chaque application ou serveur - peut pointer vers son canal dédié ; le canal "défaut" est utilisé en fallback. + Deux modes pris en charge : + SharePoint (PatchCenter écrit un fichier .txt dans un dossier OneDrive sync, + un Workflow Power Automate côté SharePoint poste sur Teams — pas d'OAuth) ; + Webhook (POST direct, OAuth Entra ID requis, futur). + Le routage serveur → canal est pris en charge par les règles.

@@ -302,9 +304,11 @@ - - + + + + {% if editable.vsphere %}{% endif %} @@ -312,9 +316,15 @@ {% for tc in teams_channels %} - - + + + + {% if editable.vsphere %} {% else %} - + {% endfor %}
NomWebhook URLDescriptionModeSP route / Webhook DéfautRebootDM dyn. ActifActions
{{ tc.name }}{{ tc.webhook_url[:60] }}{% if tc.webhook_url|length > 60 %}…{% endif %}{{ tc.description or '-' }}{{ tc.mode }} + {% if tc.mode == 'sharepoint' %}{{ tc.sp_route or '(non défini)' }} + {% else %}{{ (tc.webhook_url or '')[:60] }}{% if tc.webhook_url and tc.webhook_url|length > 60 %}…{% endif %} + {% endif %} + {{ 'Oui' if tc.is_default else '-' }}{{ 'Oui' if tc.is_reboot_channel else '-' }}{{ 'Oui' if tc.is_dynamic_dm else '-' }} {{ 'Oui' if tc.is_active else 'Non' }} @@ -327,7 +337,7 @@ {% endif %}
Aucun canal Teams configuré.
Aucun canal Teams configuré.
@@ -336,28 +346,143 @@ {% if editable.vsphere %}

Ajouter un canal Teams

-
+
- +
- + + +
+
+ + +
- - + + +
+
+ +
- +
- + {% endif %} + + +
+

Règles de routage

+

+ Les règles sont évaluées par ordre de priorité croissante (10, 20, 30…). + La première règle dont toutes les conditions actives matchent décide du canal. + Conditions vides = pas filtré. + Pour reboot, le canal flaggé "Reboot" gagne avant toute règle. +

+ + + + + + + + {% if editable.vsphere %}{% endif %} + + + {% for tr in teams_rules %} + + + + + + + {% if editable.vsphere %} + + {% endif %} + + {% else %} + + {% endfor %} + +
PrioNomMatch→ CanalActifActions
{{ tr.priority }}{{ tr.name }} + {% if tr.responsable_name %}resp: {{ tr.responsable_name }}{% endif %} + {% if tr.match_application_domain %}domain~{{ tr.match_application_domain }}{% endif %} + {% if tr.match_env_in %}env∈{{ tr.match_env_in|join(',') }}{% endif %} + {% if tr.match_msg_type_in %}msg∈{{ tr.match_msg_type_in|join(',') }}{% endif %} + {% if tr.match_hostname_pattern %}host~{{ tr.match_hostname_pattern }}{% endif %} + {{ tr.channel_name or '(canal supprimé)' }}{{ 'Oui' if tr.is_active else 'Non' }} +
+ +
+
Aucune règle. Sans règles, seul le canal "défaut" sera utilisé.
+ + {% if editable.vsphere %} +
+
Ajouter une règle
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ {% endif %} +
{% endif %} diff --git a/migrate_teams_rules_20260506.sql b/migrate_teams_rules_20260506.sql new file mode 100644 index 0000000..acb741d --- /dev/null +++ b/migrate_teams_rules_20260506.sql @@ -0,0 +1,78 @@ +-- Migration : table de règles de routage Teams + colonnes additionnelles sur teams_channels +-- Permet de copier le mécanisme Sanef Patch Manager .exe (SharePoint sync + Power Automate) +-- - sp_route : sous-dossier SharePoint où PatchCenter écrit le fichier .txt +-- - mode : 'sharepoint' (écriture fichier) | 'webhook' (POST direct, futur) +-- - is_reboot_channel : canal qui reçoit les notifs reboot (priorité sur tout autre routage) +-- - is_dynamic_dm : la route SharePoint est un router unique (workflow PA lit "TO: " en 1ère ligne) +-- - teams_channel_rules : règles de routage configurables (par responsable / domaine / env / msg_type / hostname pattern) +-- Idempotent. + +-- ─── 1) Nouvelles colonnes sur teams_channels ───────────────── + +ALTER TABLE public.teams_channels + ADD COLUMN IF NOT EXISTS sp_route text, + ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'sharepoint', + ADD COLUMN IF NOT EXISTS is_reboot_channel boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_dynamic_dm boolean NOT NULL DEFAULT false; + +-- webhook_url devient optionnel (pour les canaux mode='sharepoint' on n'a que sp_route) +ALTER TABLE public.teams_channels + ALTER COLUMN webhook_url DROP NOT NULL; + +-- Backfill cohérent : tout canal existant qui a un webhook_url renseigné mais pas de sp_route +-- bascule en mode='webhook' (sinon le CHECK qui suit échouerait) +UPDATE public.teams_channels + SET mode = 'webhook' + WHERE webhook_url IS NOT NULL AND length(webhook_url) > 0 + AND (sp_route IS NULL OR length(sp_route) = 0) + AND mode = 'sharepoint'; + +-- Contrainte cohérence : selon le mode, le champ correspondant doit être renseigné +ALTER TABLE public.teams_channels + DROP CONSTRAINT IF EXISTS teams_channels_mode_check; +ALTER TABLE public.teams_channels + ADD CONSTRAINT teams_channels_mode_check + CHECK ( + (mode = 'sharepoint' AND sp_route IS NOT NULL AND length(sp_route) > 0) + OR (mode = 'webhook' AND webhook_url IS NOT NULL AND length(webhook_url) > 0) + ); + +-- Un seul canal reboot global (unique partial index) +CREATE UNIQUE INDEX IF NOT EXISTS uq_teams_channels_one_reboot + ON public.teams_channels((is_reboot_channel)) + WHERE is_reboot_channel = true; + +-- ─── 2) Table de règles ─────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.teams_channel_rules ( + id SERIAL PRIMARY KEY, + priority INT NOT NULL DEFAULT 100, + name text NOT NULL, + + -- Conditions de match (toutes les conditions non-NULL doivent matcher) + match_responsable_contact_id INT REFERENCES public.contacts(id) ON DELETE SET NULL, + match_application_domain text, -- match insensible casse via ILIKE + match_env_in text[], -- env du serveur ∈ ce tableau + match_msg_type_in text[], -- msg_type ∈ ce tableau ; NULL = tous + match_hostname_pattern text, -- substring insensible casse dans hostname (ex 'bst') + + -- Résultat + channel_id INT NOT NULL REFERENCES public.teams_channels(id) ON DELETE CASCADE, + + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_tc_rules_active_priority + ON public.teams_channel_rules(is_active, priority) + WHERE is_active = true; + +CREATE INDEX IF NOT EXISTS idx_tc_rules_channel + ON public.teams_channel_rules(channel_id); + +-- ─── 3) GRANTs ──────────────────────────────────────────────── + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.teams_channel_rules TO patchcenter; +GRANT USAGE, SELECT ON SEQUENCE public.teams_channel_rules_id_seq TO patchcenter; + +-- Note : pas besoin de GRANT supplémentaire sur teams_channels (déjà fait dans la migration précédente)