feat(settings/teams M1+M4): UI CRUD canaux Teams + service teams_service.py (Adaptive Card via Incoming Webhook) + bouton Test webhook

This commit is contained in:
Pierre & Lumière 2026-05-05 13:58:38 +02:00
parent 9375c7ec4e
commit 075706178e
3 changed files with 340 additions and 0 deletions

View File

@ -134,6 +134,11 @@ def _build_context(db, user, saved=None):
q_linked = db.execute(text("SELECT COUNT(*) FROM servers WHERE qualys_asset_id IS NOT NULL")).scalar()
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
FROM teams_channels
ORDER BY is_default DESC, name
""")).fetchall()
# Filtrer les sections visibles selon le role
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS}
@ -145,6 +150,7 @@ def _build_context(db, user, saved=None):
"allowed_nets": allowed_nets,
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
"vcenters": vcenters, "saved": saved,
"teams_channels": teams_channels,
"visible": visible, "editable": editable,
}
@ -244,6 +250,102 @@ async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)):
return templates.TemplateResponse("settings.html", ctx)
# --- Teams channels CRUD ---
@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_description: str = Form(""),
tc_is_default: 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")
is_def = bool(tc_is_default)
if is_def:
# un seul canal par défaut
db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=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})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@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_description: str = Form(""),
tc_is_active: str = Form(""),
tc_is_default: 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")
is_def = bool(tc_is_default)
is_act = bool(tc_is_active)
if is_def:
db.execute(text("UPDATE teams_channels SET is_default=false WHERE is_default=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
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})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/teams-channel/{tc_id}/delete", response_class=HTMLResponse)
async def teams_channel_delete(request: Request, tc_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_channels WHERE id=:id"), {"id": tc_id})
db.commit()
ctx = _build_context(db, user, saved="teams")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@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."""
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_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()
if not row:
return JSONResponse({"ok": False, "msg": "Canal introuvable"}, status_code=404)
try:
from ..services.teams_service import send_test_message
result = send_test_message(row.webhook_url, row.name, user.get("username") or "PatchCenter")
return JSONResponse(result)
except Exception as e:
return JSONResponse({"ok": False, "msg": f"Erreur: {e}"}, status_code=500)
# --- Secret individuel ---
@router.post("/settings/secret/update", response_class=HTMLResponse)

View File

@ -0,0 +1,145 @@
"""Service Microsoft Teams — envoi de messages via Incoming Webhook.
Webhook = simple, pas d'auth Azure AD requise.
Format : Adaptive Card (titre + corps) ou simple "text" en fallback.
À 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.
"""
import json
import logging
from datetime import datetime
from typing import Dict, Any
import requests
log = logging.getLogger("patchcenter.teams")
HTTP_TIMEOUT = 10
PROXY_URL = None # à override via Settings si besoin
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 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)

View File

@ -279,6 +279,89 @@
</div>
{% endif %}
<!-- Teams Channels -->
{% if visible.vsphere %}
<div class="card overflow-hidden">
<button @click="open = open === 'teams' ? '' : 'teams'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<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>
</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">
<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.
</p>
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Canaux enregistrés</h4>
<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">Défaut</th>
<th class="p-2">Actif</th>
{% if editable.vsphere %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% 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.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_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">
<button type="button" class="btn-sm bg-cyber-blue/20 text-cyber-blue text-xs"
onclick="testTeams({{ tc.id }}, '{{ tc.name|e }}')">Test</button>
<form method="POST" action="/settings/teams-channel/{{ tc.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red text-xs" onclick="return confirm('Supprimer ce canal Teams ?')">Suppr</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="6" class="p-3 text-gray-500 text-center">Aucun canal Teams configuré.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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>
<label class="text-xs text-gray-500">Nom</label>
<input type="text" name="tc_name" placeholder="SECOPS Patching" 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>
</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>
</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">
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
<!-- Sécurité -->
{% if visible.security %}
<div class="card overflow-hidden">
@ -537,5 +620,15 @@ function testLdap() {
})
.catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; });
}
function testTeams(tcId, tcName) {
fetch('/settings/teams-channel/' + tcId + '/test', {method: 'POST', credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) alert('✓ Test envoyé sur "' + tcName + '" — vérifie le canal Teams (HTTP ' + d.status + ')');
else alert('✗ Échec test "' + tcName + '" — HTTP ' + (d.status || '?') + '\n' + (d.detail || d.msg || ''));
})
.catch(function(e){ alert('✗ Erreur réseau : ' + e.message); });
}
</script>
{% endblock %}