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 <sp_base>/<sp_route>/),
  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: <email>' 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
This commit is contained in:
Pierre & Lumière 2026-05-06 09:57:42 +02:00
parent 6839b3e59d
commit edec1f7db5
4 changed files with 716 additions and 87 deletions

View File

@ -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 <sharepoint_notif_path>/<sp_route>/
- 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")
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)

View File

@ -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: <email>` 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 <sp_base>/<sp_route>/.
Format nom de fichier : <msg_type>_<server>_<YYYYMMDD_HHMMSS>.txt
Format contenu :
- Si dynamic_to_email : 1ère ligne `TO: <email>`, 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: <email>`.
Retourne {
ok: bool,
channel_id, name, mode, sp_route, webhook_url, is_dynamic_dm,
dynamic_to_email (str | None),
source ('reboot'|'server_override'|'rule:<name>'|'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]:

View File

@ -286,15 +286,17 @@
<div class="flex items-center gap-3">
<span class="text-cyber-accent font-bold">Canaux Microsoft Teams</span>
<span class="badge badge-gray">Notifications</span>
<span class="text-xs text-gray-500">{{ teams_channels|selectattr('is_active')|list|length }} actif(s){% if teams_channels|selectattr('is_default')|list|length %} · 1 défaut{% endif %}</span>
<span class="text-xs text-gray-500">{{ 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 %}</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'teams' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'teams'" class="border-t border-cyber-border p-4 space-y-4">
<div x-show="open === 'teams'" class="border-t border-cyber-border p-4 space-y-6">
<p class="text-xs text-gray-500">
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 :
<strong>SharePoint</strong> (PatchCenter écrit un fichier .txt dans un dossier OneDrive sync,
un Workflow Power Automate côté SharePoint poste sur Teams — pas d'OAuth) ;
<strong>Webhook</strong> (POST direct, OAuth Entra ID requis, futur).
Le routage serveur → canal est pris en charge par les <em>règles</em>.
</p>
<div>
@ -302,9 +304,11 @@
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">Nom</th>
<th class="text-left p-2">Webhook URL</th>
<th class="text-left p-2">Description</th>
<th class="p-2">Mode</th>
<th class="text-left p-2">SP route / Webhook</th>
<th class="p-2">Défaut</th>
<th class="p-2">Reboot</th>
<th class="p-2">DM dyn.</th>
<th class="p-2">Actif</th>
{% if editable.vsphere %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
@ -312,9 +316,15 @@
{% for tc in teams_channels %}
<tr>
<td class="p-2 font-bold">{{ tc.name }}</td>
<td class="p-2 font-mono text-[10px] text-cyber-accent" style="max-width:380px; overflow:hidden; text-overflow:ellipsis;" title="{{ tc.webhook_url }}">{{ tc.webhook_url[:60] }}{% if tc.webhook_url|length > 60 %}…{% endif %}</td>
<td class="p-2 text-xs text-gray-400">{{ tc.description or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if tc.mode == 'sharepoint' %}badge-blue{% else %}badge-gray{% endif %}">{{ tc.mode }}</span></td>
<td class="p-2 font-mono text-[10px]" style="max-width:380px; overflow:hidden; text-overflow:ellipsis;" title="{{ tc.sp_route or tc.webhook_url or '' }}">
{% 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 %}
</td>
<td class="p-2 text-center"><span class="badge {% if tc.is_default %}badge-green{% else %}badge-gray{% endif %}">{{ 'Oui' if tc.is_default else '-' }}</span></td>
<td class="p-2 text-center"><span class="badge {% if tc.is_reboot_channel %}badge-orange{% else %}badge-gray{% endif %}">{{ 'Oui' if tc.is_reboot_channel else '-' }}</span></td>
<td class="p-2 text-center"><span class="badge {% if tc.is_dynamic_dm %}badge-blue{% else %}badge-gray{% endif %}">{{ 'Oui' if tc.is_dynamic_dm else '-' }}</span></td>
<td class="p-2 text-center"><span class="badge {% if tc.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if tc.is_active else 'Non' }}</span></td>
{% if editable.vsphere %}
<td class="p-2 text-center whitespace-nowrap">
@ -327,7 +337,7 @@
{% endif %}
</tr>
{% else %}
<tr><td colspan="6" class="p-3 text-gray-500 text-center">Aucun canal Teams configuré.</td></tr>
<tr><td colspan="8" class="p-3 text-gray-500 text-center">Aucun canal Teams configuré.</td></tr>
{% endfor %}
</tbody>
</table>
@ -336,28 +346,143 @@
{% if editable.vsphere %}
<form method="POST" action="/settings/teams-channel/add" class="space-y-3 pt-2 border-t border-cyber-border">
<h4 class="text-xs text-cyber-accent font-bold uppercase">Ajouter un canal Teams</h4>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Nom</label>
<input type="text" name="tc_name" placeholder="SECOPS Patching" class="w-full" required>
<input type="text" name="tc_name" placeholder="ex: Suivi patch LAN" class="w-full" required>
</div>
<div>
<label class="text-xs text-gray-500">
<input type="checkbox" name="tc_is_default" value="1" class="mr-1"> Défaut (un seul canal par défaut)
</label>
<label class="text-xs text-gray-500">Mode</label>
<select name="tc_mode" class="w-full">
<option value="sharepoint" selected>SharePoint (recommandé)</option>
<option value="webhook">Webhook (OAuth requis)</option>
</select>
</div>
<div class="flex flex-col text-xs text-gray-500 gap-1 pt-4">
<label><input type="checkbox" name="tc_is_default" value="1" class="mr-1"> Défaut</label>
<label><input type="checkbox" name="tc_is_reboot_channel" value="1" class="mr-1"> Canal reboot</label>
<label><input type="checkbox" name="tc_is_dynamic_dm" value="1" class="mr-1"> DM dynamique (TO: en 1ère ligne)</label>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Webhook URL (Workflows ou Incoming Webhook)</label>
<input type="url" name="tc_webhook_url" placeholder="https://prod-XX.westeurope.logic.azure.com:443/workflows/..." class="w-full font-mono text-xs" required>
<label class="text-xs text-gray-500">Sous-dossier SharePoint (sp_route) — requis si mode = sharepoint</label>
<input type="text" name="tc_sp_route" placeholder="ex: lan_delcour, fl_prod, peage, dsi_general, dm_router" class="w-full font-mono text-xs">
</div>
<div>
<label class="text-xs text-gray-500">Webhook URL — requis si mode = webhook (laisser vide en mode sharepoint)</label>
<input type="url" name="tc_webhook_url" placeholder="https://...api.powerplatform.com/...." class="w-full font-mono text-xs">
</div>
<div>
<label class="text-xs text-gray-500">Description (optionnelle)</label>
<input type="text" name="tc_description" placeholder="Canal de l'équipe SECOPS pour annonces patching" class="w-full">
<input type="text" name="tc_description" placeholder="ex: Conv groupe Patching Suivi LAN équipe Delcour" class="w-full">
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter</button>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter le canal</button>
</form>
{% endif %}
<!-- Règles de routage -->
<div class="pt-4 border-t border-cyber-border">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Règles de routage</h4>
<p class="text-xs text-gray-500 mb-3">
Les règles sont évaluées par ordre de priorité croissante (10, 20, 30…).
La première règle dont <em>toutes</em> les conditions actives matchent décide du canal.
Conditions vides = pas filtré.
Pour <em>reboot</em>, le canal flaggé "Reboot" gagne avant toute règle.
</p>
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="p-2">Prio</th>
<th class="text-left p-2">Nom</th>
<th class="text-left p-2">Match</th>
<th class="text-left p-2">→ Canal</th>
<th class="p-2">Actif</th>
{% if editable.vsphere %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% for tr in teams_rules %}
<tr>
<td class="p-2 text-center font-mono">{{ tr.priority }}</td>
<td class="p-2 font-bold">{{ tr.name }}</td>
<td class="p-2 text-xs">
{% if tr.responsable_name %}<span class="badge badge-gray">resp: {{ tr.responsable_name }}</span>{% endif %}
{% if tr.match_application_domain %}<span class="badge badge-gray">domain~{{ tr.match_application_domain }}</span>{% endif %}
{% if tr.match_env_in %}<span class="badge badge-gray">env∈{{ tr.match_env_in|join(',') }}</span>{% endif %}
{% if tr.match_msg_type_in %}<span class="badge badge-orange">msg∈{{ tr.match_msg_type_in|join(',') }}</span>{% endif %}
{% if tr.match_hostname_pattern %}<span class="badge badge-gray">host~{{ tr.match_hostname_pattern }}</span>{% endif %}
</td>
<td class="p-2">{{ tr.channel_name or '(canal supprimé)' }}</td>
<td class="p-2 text-center"><span class="badge {% if tr.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if tr.is_active else 'Non' }}</span></td>
{% if editable.vsphere %}
<td class="p-2 text-center whitespace-nowrap">
<form method="POST" action="/settings/teams-rule/{{ tr.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red text-xs" onclick="return confirm('Supprimer cette règle ?')">Suppr</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="6" class="p-3 text-gray-500 text-center">Aucune règle. Sans règles, seul le canal "défaut" sera utilisé.</td></tr>
{% endfor %}
</tbody>
</table>
{% if editable.vsphere %}
<form method="POST" action="/settings/teams-rule/add" class="space-y-3 mt-3 pt-3 border-t border-cyber-border">
<h5 class="text-xs text-cyber-accent font-bold uppercase">Ajouter une règle</h5>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Priorité (asc)</label>
<input type="number" name="tr_priority" value="100" class="w-full">
</div>
<div class="col-span-2">
<label class="text-xs text-gray-500">Nom</label>
<input type="text" name="tr_name" placeholder="ex: Delcour LAN debut/fin" class="w-full" required>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Canal cible</label>
<select name="tr_channel_id" class="w-full" required>
<option value="">-- Choisir --</option>
{% for tc in teams_channels %}
<option value="{{ tc.id }}">{{ tc.name }} ({{ tc.mode }}{% if tc.sp_route %}/{{ tc.sp_route }}{% endif %})</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Match responsable (contact)</label>
<select name="tr_match_responsable_contact_id" class="w-full">
<option value="">(aucun filtre)</option>
{% for c in contacts_list %}
<option value="{{ c.id }}">{{ c.name }}{% if c.teams_upn %} — {{ c.teams_upn }}{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Match domaine appli (substring, insensible casse)</label>
<input type="text" name="tr_match_application_domain" placeholder="ex: Flux Libre" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Match env (CSV — ex: Production / Test,Dev,Recette,Préprod)</label>
<input type="text" name="tr_match_env_in" placeholder="ex: Production" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Match msg_type (CSV — debut,fin,reboot,annulation)</label>
<input type="text" name="tr_match_msg_type_in" placeholder="ex: debut,fin" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Match hostname (substring, insensible casse — ex: bst)</label>
<input type="text" name="tr_match_hostname_pattern" placeholder="ex: bst" class="w-full">
</div>
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter la règle</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endif %}

View File

@ -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: <email>" 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)