- 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
413 lines
18 KiB
Python
413 lines
18 KiB
Python
"""Service Microsoft Teams — deux modes d'envoi :
|
|
|
|
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.
|
|
|
|
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, Optional, List
|
|
|
|
import requests
|
|
|
|
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:
|
|
return {"http": PROXY_URL, "https": PROXY_URL}
|
|
return None
|
|
|
|
|
|
def _post(webhook_url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Poste un payload sur le webhook Teams. Retourne {ok, status, detail}."""
|
|
try:
|
|
r = requests.post(webhook_url, json=payload,
|
|
timeout=HTTP_TIMEOUT, proxies=_proxies())
|
|
ok = (200 <= r.status_code < 300)
|
|
return {
|
|
"ok": ok,
|
|
"status": r.status_code,
|
|
"detail": (r.text or "")[:500] if not ok else "Message envoyé",
|
|
}
|
|
except requests.exceptions.RequestException as e:
|
|
return {"ok": False, "status": 0, "detail": f"Réseau: {e}"}
|
|
|
|
|
|
def _adaptive_card(title: str, body_lines: list, color: str = "good") -> Dict[str, Any]:
|
|
"""Construit un payload Adaptive Card minimal (compatible Workflows).
|
|
color : 'good' (vert) | 'warning' (orange) | 'attention' (rouge) | 'default'."""
|
|
facts = []
|
|
body_blocks = [
|
|
{"type": "TextBlock", "text": title, "weight": "Bolder",
|
|
"size": "Medium", "color": color, "wrap": True}
|
|
]
|
|
for ln in body_lines:
|
|
if isinstance(ln, dict) and "title" in ln and "value" in ln:
|
|
facts.append({"title": ln["title"], "value": str(ln["value"])})
|
|
else:
|
|
body_blocks.append({"type": "TextBlock", "text": str(ln), "wrap": True,
|
|
"spacing": "Small"})
|
|
if facts:
|
|
body_blocks.append({"type": "FactSet", "facts": facts})
|
|
body_blocks.append({"type": "TextBlock",
|
|
"text": f"_{datetime.now().strftime('%Y-%m-%d %H:%M')} — PatchCenter_",
|
|
"size": "Small", "isSubtle": True, "spacing": "Medium"})
|
|
return {
|
|
"type": "message",
|
|
"attachments": [{
|
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
|
"contentUrl": None,
|
|
"content": {
|
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
"type": "AdaptiveCard",
|
|
"version": "1.4",
|
|
"body": body_blocks,
|
|
}
|
|
}]
|
|
}
|
|
|
|
|
|
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"}
|
|
|
|
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
|
|
LEFT JOIN applications a ON a.id = s.application_id
|
|
WHERE s.id = :sid
|
|
"""), {"sid": server_id}).fetchone()
|
|
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 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]:
|
|
"""Envoie un message de test pour valider la config du webhook."""
|
|
payload = _adaptive_card(
|
|
title=f"✅ Test PatchCenter — canal '{channel_name}'",
|
|
body_lines=[
|
|
"Ce message confirme que le webhook Teams est correctement configuré.",
|
|
{"title": "Envoyé par", "value": sender},
|
|
{"title": "Source", "value": "PatchCenter > Settings > Teams"},
|
|
],
|
|
color="good",
|
|
)
|
|
return _post(webhook_url, payload)
|
|
|
|
|
|
def send_intervention_start(webhook_url: str, hostname: str, application: str,
|
|
intervenant: str, planned_at: str = None) -> Dict[str, Any]:
|
|
"""Annonce le DÉBUT d'une intervention de patching."""
|
|
body = [
|
|
{"title": "Serveur", "value": hostname},
|
|
{"title": "Application", "value": application or "—"},
|
|
{"title": "Intervenant", "value": intervenant or "—"},
|
|
]
|
|
if planned_at:
|
|
body.append({"title": "Prévu", "value": planned_at})
|
|
payload = _adaptive_card(
|
|
title=f"🟧 Début intervention patching — {hostname}",
|
|
body_lines=body,
|
|
color="warning",
|
|
)
|
|
return _post(webhook_url, payload)
|
|
|
|
|
|
def send_intervention_end(webhook_url: str, hostname: str, application: str,
|
|
intervenant: str, status: str = "ok") -> Dict[str, Any]:
|
|
"""Annonce la FIN d'une intervention. status = 'ok' | 'ko'."""
|
|
color = "good" if status == "ok" else "attention"
|
|
icon = "✅" if status == "ok" else "❌"
|
|
payload = _adaptive_card(
|
|
title=f"{icon} Fin intervention patching — {hostname}",
|
|
body_lines=[
|
|
{"title": "Serveur", "value": hostname},
|
|
{"title": "Application", "value": application or "—"},
|
|
{"title": "Intervenant", "value": intervenant or "—"},
|
|
{"title": "Statut technique", "value": status.upper()},
|
|
"_(En attente de validation par le responsable applicatif)_",
|
|
],
|
|
color=color,
|
|
)
|
|
return _post(webhook_url, payload)
|
|
|
|
|
|
def send_planning_reminder(webhook_url: str, hostname: str, application: str,
|
|
jour: str, heure: str, intervenant: str) -> Dict[str, Any]:
|
|
"""Rappel de planning (envoyable du jeudi/vendredi précédent jusqu'au jour J)."""
|
|
payload = _adaptive_card(
|
|
title=f"🗓 Rappel planning patching — {hostname}",
|
|
body_lines=[
|
|
{"title": "Serveur", "value": hostname},
|
|
{"title": "Application", "value": application or "—"},
|
|
{"title": "Date prévue", "value": jour or "—"},
|
|
{"title": "Heure prévue", "value": heure or "—"},
|
|
{"title": "Intervenant", "value": intervenant or "—"},
|
|
"_Merci de confirmer la disponibilité applicative._",
|
|
],
|
|
color="default",
|
|
)
|
|
return _post(webhook_url, payload)
|