patchcenter/app/services/teams_service.py
Admin MPCZ edec1f7db5 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
2026-05-06 09:57:42 +02:00

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)