From edec1f7db56be85ead7ad10502926f5813c957c8 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Wed, 6 May 2026 09:57:42 +0200 Subject: [PATCH] feat(teams): mode SharePoint sync (calque .exe Sanef Patch Manager) + rules-based routing - Migration: ajoute sp_route/mode/is_reboot_channel/is_dynamic_dm sur teams_channels, cree table teams_channel_rules (match resp/domain/env/msg_type/hostname pattern) - Service teams_service.py: format texte plat compatible workflows existants, write_sharepoint_notification (ecrit fichier .txt dans //), resolve_channel_for_server rules-based avec priorite reboot, send_notification orchestre resolution + envoi - Settings UI: CRUD canaux etendu (mode SP/webhook + flags reboot/dyn_dm), CRUD regles avec match conditions, sharepoint_notif_path en secret app, bouton Test ecrit fichier .txt en mode SP - Mode is_dynamic_dm: prefixe le contenu par 'TO: ' pour permettre un workflow PA unique qui dispatch dynamiquement aux responsables - Pas d'OAuth requis: PatchCenter ecrit fichiers, Workflows PA cote SharePoint (deja en place pour le .exe) declenchent et postent sur Teams Mode webhook conserve mais inactif tant qu'OAuth Entra ID pas mis en place chez SANEF --- app/routers/settings.py | 257 +++++++++++++++++++++++--- app/services/teams_service.py | 305 ++++++++++++++++++++++++++----- app/templates/settings.html | 163 +++++++++++++++-- migrate_teams_rules_20260506.sql | 78 ++++++++ 4 files changed, 716 insertions(+), 87 deletions(-) create mode 100644 migrate_teams_rules_20260506.sql 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)