diff --git a/app/routers/settings.py b/app/routers/settings.py index 29a654e..ba110aa 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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) diff --git a/app/services/teams_service.py b/app/services/teams_service.py new file mode 100644 index 0000000..34c5459 --- /dev/null +++ b/app/services/teams_service.py @@ -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) diff --git a/app/templates/settings.html b/app/templates/settings.html index e7efe3a..5b04dda 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -279,6 +279,89 @@ {% endif %} + + {% if visible.vsphere %} +
+ 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. +
+ +| Nom | +Webhook URL | +Description | +Défaut | +Actif | + {% if editable.vsphere %}Actions | {% endif %} +
|---|---|---|---|---|---|
| {{ tc.name }} | + +{{ tc.description or '-' }} | +{{ 'Oui' if tc.is_default else '-' }} | +{{ 'Oui' if tc.is_active else 'Non' }} | + {% if editable.vsphere %} ++ + + | + {% endif %} +|
| Aucun canal Teams configuré. | |||||