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:
parent
9375c7ec4e
commit
075706178e
@ -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)
|
||||
|
||||
145
app/services/teams_service.py
Normal file
145
app/services/teams_service.py
Normal 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)
|
||||
@ -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' ? '▼' : '▶'"></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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user