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()
|
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()
|
vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall()
|
||||||
allowed_nets = db.execute(text("SELECT * FROM allowed_networks ORDER BY cidr")).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
|
# Filtrer les sections visibles selon le role
|
||||||
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS}
|
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,
|
"allowed_nets": allowed_nets,
|
||||||
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
|
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
|
||||||
"vcenters": vcenters, "saved": saved,
|
"vcenters": vcenters, "saved": saved,
|
||||||
|
"teams_channels": teams_channels,
|
||||||
"visible": visible, "editable": editable,
|
"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)
|
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 ---
|
# --- Secret individuel ---
|
||||||
|
|
||||||
@router.post("/settings/secret/update", response_class=HTMLResponse)
|
@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>
|
</div>
|
||||||
{% endif %}
|
{% 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é -->
|
<!-- Sécurité -->
|
||||||
{% if visible.security %}
|
{% if visible.security %}
|
||||||
<div class="card overflow-hidden">
|
<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'; });
|
.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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user