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:
parent
6839b3e59d
commit
edec1f7db5
@ -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)
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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' ? '▼' : '▶'"></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 %}
|
||||
|
||||
78
migrate_teams_rules_20260506.sql
Normal file
78
migrate_teams_rules_20260506.sql
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user