From 060af01db92d1ca2daa8bf369a4d49c17d27190b Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Wed, 6 May 2026 10:33:12 +0200 Subject: [PATCH] feat(teams): fan-out multi-recipient + flag is_database_server + multi-referents - Migration v2: ajoute teams_channel_rules.match_is_database_server (NULL=any/TRUE=DB only/FALSE=non-DB only), servers.is_database_server boolean (default false), table server_additional_referents pour multi-referents - Service teams_service.py: resolve_channel_for_server -> resolve_channels_for_server (pluriel, retourne LIST) * msg_type=reboot: 1 seul canal (canal flagge is_reboot_channel) * server.teams_channel_id override: 1 seul canal * sinon FAN-OUT: TOUTES les regles actives qui matchent contribuent un canal * dynamic_to_email calcule selon nature de la regle (responsable / DBA pool / referents) - send_notification: boucle sur les canaux resultants, ecrit 1 fichier SP par destination - UI settings.html: nouveau filtre 'Match serveur DB' dans formulaire regle, badge dans tableau - Compat: resolve_channel_for_server (singulier) conserve comme wrapper qui retourne le 1er canal Permet le scenario: serveur DB avec responsable=Delcour -> notif a la fois dans conv Delcour ET dans conv DBA (Nadine+Cedric), via 2 regles qui matchent en parallele. --- app/routers/settings.py | 31 +++- app/services/teams_service.py | 232 ++++++++++++++++++++-------- app/templates/settings.html | 14 +- migrate_teams_rules_v2_20260506.sql | 39 +++++ 4 files changed, 240 insertions(+), 76 deletions(-) create mode 100644 migrate_teams_rules_v2_20260506.sql diff --git a/app/routers/settings.py b/app/routers/settings.py index 1ee04e1..22f3fd4 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -145,6 +145,7 @@ def _build_context(db, user, saved=None): SELECT r.id, r.priority, r.name, r.match_responsable_contact_id, r.match_application_domain, r.match_env_in, r.match_msg_type_in, r.match_hostname_pattern, + r.match_is_database_server, r.channel_id, r.is_active, r.created_at, tc.name AS channel_name, c.name AS responsable_name @@ -446,6 +447,16 @@ def _parse_csv_text_array(s: str): return items or None +def _parse_db_filter(s: str): + """'true'/'1'/'yes' -> True ; 'false'/'0'/'no' -> False ; '' -> None (pas filtré).""" + if s is None: return None + s = s.strip().lower() + if s in ("", "any", "null", "none", "-"): return None + if s in ("true", "1", "yes", "y", "oui", "db"): return True + if s in ("false", "0", "no", "n", "non", "nondb", "non-db"): return False + return None + + @router.post("/settings/teams-rule/add", response_class=HTMLResponse) async def teams_rule_add(request: Request, db=Depends(get_db), tr_priority: int = Form(100), @@ -455,7 +466,8 @@ async def teams_rule_add(request: Request, db=Depends(get_db), tr_match_application_domain: str = Form(""), tr_match_env_in: str = Form(""), tr_match_msg_type_in: str = Form(""), - tr_match_hostname_pattern: str = Form("")): + tr_match_hostname_pattern: str = Form(""), + tr_match_is_database_server: str = Form("")): user = get_current_user(request) if not user: return RedirectResponse(url="/login") @@ -467,8 +479,9 @@ async def teams_rule_add(request: Request, db=Depends(get_db), INSERT INTO teams_channel_rules (priority, name, channel_id, match_responsable_contact_id, match_application_domain, - match_env_in, match_msg_type_in, match_hostname_pattern) - VALUES (:p, :n, :ch, :rc, :dom, :env, :mt, :host) + match_env_in, match_msg_type_in, match_hostname_pattern, + match_is_database_server) + VALUES (:p, :n, :ch, :rc, :dom, :env, :mt, :host, :db) """), { "p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id, "rc": resp_id, @@ -476,6 +489,7 @@ async def teams_rule_add(request: Request, db=Depends(get_db), "env": _parse_csv_text_array(tr_match_env_in), "mt": _parse_csv_text_array(tr_match_msg_type_in), "host": (tr_match_hostname_pattern.strip() or None), + "db": _parse_db_filter(tr_match_is_database_server), }) db.commit() ctx = _build_context(db, user, saved="teams") @@ -493,6 +507,7 @@ async def teams_rule_edit(request: Request, tr_id: int, db=Depends(get_db), tr_match_env_in: str = Form(""), tr_match_msg_type_in: str = Form(""), tr_match_hostname_pattern: str = Form(""), + tr_match_is_database_server: str = Form(""), tr_is_active: str = Form("")): user = get_current_user(request) if not user: @@ -508,6 +523,7 @@ async def teams_rule_edit(request: Request, tr_id: int, db=Depends(get_db), match_application_domain=:dom, match_env_in=:env, match_msg_type_in=:mt, match_hostname_pattern=:host, + match_is_database_server=:db, is_active=:ia WHERE id=:id """), { @@ -517,6 +533,7 @@ async def teams_rule_edit(request: Request, tr_id: int, db=Depends(get_db), "env": _parse_csv_text_array(tr_match_env_in), "mt": _parse_csv_text_array(tr_match_msg_type_in), "host": (tr_match_hostname_pattern.strip() or None), + "db": _parse_db_filter(tr_match_is_database_server), "ia": bool(tr_is_active), "id": tr_id, }) db.commit() @@ -544,8 +561,8 @@ async def teams_rule_delete(request: Request, tr_id: int, db=Depends(get_db)): async def teams_rule_test(request: Request, db=Depends(get_db), server_id: int = Form(...), msg_type: str = Form("debut")): - """Teste la résolution sans envoyer : retourne quel canal serait choisi pour - (server_id, msg_type) et pourquoi (source de la décision).""" + """Teste la résolution sans envoyer : retourne la liste des canaux qui seraient + notifiés pour (server_id, msg_type), avec la source (rule: / reboot / default).""" from fastapi.responses import JSONResponse user = get_current_user(request) if not user: @@ -553,8 +570,8 @@ async def teams_rule_test(request: Request, db=Depends(get_db), perms = get_user_perms(db, user) if not can_view(perms, "settings"): return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) - from ..services.teams_service import resolve_channel_for_server - result = resolve_channel_for_server(db, server_id, msg_type) + from ..services.teams_service import resolve_channels_for_server + result = resolve_channels_for_server(db, server_id, msg_type) return JSONResponse(result) diff --git a/app/services/teams_service.py b/app/services/teams_service.py index dce9127..e753c23 100644 --- a/app/services/teams_service.py +++ b/app/services/teams_service.py @@ -163,31 +163,42 @@ def _adaptive_card(title: str, body_lines: list, color: str = "good") -> Dict[st } -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. +def resolve_channels_for_server(db, server_id: int, msg_type: str = "debut") -> Dict[str, Any]: + """Résout la liste des canaux Teams cibles pour un serveur donné, 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 + 1. Si msg_type='reboot' → uniquement le canal `is_reboot_channel=true` + (PAS de fan-out ; les reboots vont à un seul endroit, le canal général DSI). + 2. servers.teams_channel_id (override explicite) → uniquement ce canal, + pas de fan-out non plus (override = "je sais ce que je veux"). + 3. Sinon : **fan-out** de toutes les règles actives qui matchent. + Toutes les règles dont les conditions matchent contribuent un canal + (multi-recipient : responsable + référents techniques + DBA + …). + 4. Si aucune règle n'a matché → fallback canal `is_default=true`. - 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: `. + Conditions de match d'une règle : + - match_msg_type_in : msg_type doit être dans le tableau (NULL = tous) + - match_responsable_contact_id : doit être == servers.responsable_domaine_contact_id + - match_application_domain : substring insensible casse dans applications.domain + - match_env_in : env du serveur dans le tableau (insensible casse) + - match_hostname_pattern : substring insensible casse dans hostname + - match_is_database_server : NULL = pas filtré ; true = ne matche que les DB ; + false = ne matche que les non-DB + + Pour chaque canal `is_dynamic_dm=true` retenu, on calcule un `dynamic_to_email` + distinct selon la nature de la règle (responsable/référent/DBA pool). Retourne { ok: bool, - channel_id, name, mode, sp_route, webhook_url, is_dynamic_dm, - dynamic_to_email (str | None), - source ('reboot'|'server_override'|'rule:'|'default'), msg (si ok=False), + server: {id, hostname, ...}, + channels: list[ {channel_id, name, mode, sp_route, webhook_url, + is_dynamic_dm, dynamic_to_email, source} ], } """ from sqlalchemy import text as sqlt if not server_id: - return {"ok": False, "msg": "server_id manquant"} + return {"ok": False, "msg": "server_id manquant", "channels": []} def _channel_row(channel_id): return db.execute(sqlt(""" @@ -196,43 +207,50 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D 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: + def _contact_upn(contact_id): + if not contact_id: return None - c = db.execute(sqlt(""" - SELECT teams_upn FROM contacts WHERE id = :id - """), {"id": server_row.responsable_domaine_contact_id}).fetchone() + c = db.execute(sqlt("SELECT teams_upn FROM contacts WHERE id = :id"), + {"id": 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 + COALESCE(s.is_database_server, false) AS is_db, + a.domain AS app_domain 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"} + return {"ok": False, "msg": f"Serveur id={server_id} introuvable", "channels": []} - def _build_result(ch, source): - if not ch: - return None - dyn_email = _resolve_dynamic_to_email(s) if ch.is_dynamic_dm else None + server_info = { + "id": s.id, "hostname": s.hostname, "environnement": s.environnement, + "domain": s.app_domain, "is_database_server": s.is_db, + "responsable_domaine_contact_id": s.responsable_domaine_contact_id, + "referent_technique_contact_id": s.referent_technique_contact_id, + } + + # Référents additionnels (multi-référents) + extra_referents = db.execute(sqlt(""" + SELECT contact_id FROM server_additional_referents WHERE server_id = :sid + """), {"sid": server_id}).fetchall() + extra_ref_ids = [r.contact_id for r in extra_referents] + + def _build(ch, source, dynamic_to_email=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, + "dynamic_to_email": dynamic_to_email, "source": source, } - # 1) Reboot → canal flaggé reboot (priorité absolue) + # 1) Reboot → canal reboot uniquement if msg_type == "reboot": ch = db.execute(sqlt(""" SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm @@ -241,20 +259,26 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D ORDER BY id LIMIT 1 """)).fetchone() if ch: - return _build_result(ch, "reboot") - # Sinon on continue, peut-être un canal default routera + dyn = _contact_upn(s.responsable_domaine_contact_id) if ch.is_dynamic_dm else None + return {"ok": True, "server": server_info, + "channels": [_build(ch, "reboot", dyn)]} + return {"ok": False, "msg": "Aucun canal reboot configuré (is_reboot_channel)", + "server": server_info, "channels": []} - # 2) Override par serveur + # 2) Override par serveur (gagne sur tout le reste) if s.server_channel_id: ch = _channel_row(s.server_channel_id) if ch: - return _build_result(ch, "server_override") + dyn = _contact_upn(s.responsable_domaine_contact_id) if ch.is_dynamic_dm else None + return {"ok": True, "server": server_info, + "channels": [_build(ch, "server_override", dyn)]} - # 3) Règles (ordre priority asc) + # 3) Fan-out toutes les règles actives qui matchent 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 + match_env_in, match_msg_type_in, match_hostname_pattern, + match_is_database_server FROM teams_channel_rules WHERE is_active = true ORDER BY priority ASC, id ASC @@ -264,6 +288,9 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D server_dom = (s.app_domain or "").strip() server_host = (s.hostname or "").lower() + matched_channels = [] + seen_channel_ids = set() + for r in rules: if r.match_msg_type_in and msg_type not in r.match_msg_type_in: continue @@ -282,12 +309,46 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D pat = r.match_hostname_pattern.strip().lower() if pat and pat not in server_host: continue - # Tout matche + if r.match_is_database_server is not None: + if bool(r.match_is_database_server) != bool(s.is_db): + continue + # Toutes conditions matchent ch = _channel_row(r.channel_id) - if ch: - return _build_result(ch, f"rule:{r.name}") + if not ch: + continue + if ch.id in seen_channel_ids: + continue # dédoublonnage si plusieurs règles pointent sur même canal + seen_channel_ids.add(ch.id) - # 4) Default global + # Détermine le dynamic_to_email selon nature de la règle + # Heuristique : si la règle matche un responsable_contact_id → DM ce contact + # sinon si match_is_database_server=true → pool DBA (gérée côté workflow PA) + # sinon → DM le responsable_domaine du serveur + dyn = None + if ch.is_dynamic_dm: + if r.match_responsable_contact_id: + dyn = _contact_upn(r.match_responsable_contact_id) + elif r.match_is_database_server: + # Pour DB : le workflow PA est censé connaître les destinataires + # (Nadine + Cedric). On peut joindre les UPN via les référents + # additionnels du serveur si renseignés, sinon vide → workflow décide. + upns = [] + if s.referent_technique_contact_id: + u = _contact_upn(s.referent_technique_contact_id) + if u: upns.append(u) + for cid in extra_ref_ids: + u = _contact_upn(cid) + if u and u not in upns: upns.append(u) + dyn = ",".join(upns) if upns else None + else: + dyn = _contact_upn(s.responsable_domaine_contact_id) + + matched_channels.append(_build(ch, f"rule:{r.name}", dyn)) + + if matched_channels: + return {"ok": True, "server": server_info, "channels": matched_channels} + + # 4) Fallback default ch = db.execute(sqlt(""" SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm FROM teams_channels @@ -295,47 +356,84 @@ def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> D ORDER BY id LIMIT 1 """)).fetchone() if ch: - return _build_result(ch, "default") + dyn = _contact_upn(s.responsable_domaine_contact_id) if ch.is_dynamic_dm else None + return {"ok": True, "server": server_info, + "channels": [_build(ch, "default", dyn)]} - return {"ok": False, "msg": "Aucun canal Teams résolu (aucune règle ne matche, pas de canal défaut)"} + return {"ok": False, "msg": "Aucun canal résolu (aucune règle ne matche, pas de canal défaut)", + "server": server_info, "channels": []} + + +# Compat : ancien nom singulier — retourne le 1er canal résolu (best-effort). +# Conservé pour ne pas casser d'éventuels callers externes ; à terme à supprimer. +def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> Dict[str, Any]: + res = resolve_channels_for_server(db, server_id, msg_type) + if not res.get("ok") or not res.get("channels"): + return {"ok": False, "msg": res.get("msg", "Aucun canal résolu")} + ch = res["channels"][0] + return { + "ok": True, + "channel_id": ch["channel_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": ch["dynamic_to_email"], "source": ch["source"], + } 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. + """Orchestration haut niveau : résout les canaux et envoie 1 notif par canal. - 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). + Mode 'sharepoint' : écrit un fichier .txt par canal (fan-out multi-recipient). + Mode 'webhook' : non implémenté tant qu'OAuth pas en place. - Retourne le résultat de l'envoi (ok, detail, …) enrichi du contexte de résolution. + Retourne {ok, server, results: list[ {channel_name, source, ok, detail/path} ]}. """ from .secrets_service import get_secret - res = resolve_channel_for_server(db, server_id, msg_type) + res = resolve_channels_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" + from sqlalchemy import text as sqlt + server_name = db.execute(sqlt("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 + sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip() - if res["mode"] == "webhook": - return {"ok": False, "msg": "Mode webhook non implémenté (OAuth Entra ID requis)", - "channel": res["name"]} + results = [] + for ch in res["channels"]: + if ch["mode"] == "sharepoint": + if not sp_base: + results.append({ + "channel_name": ch["name"], "source": ch["source"], + "ok": False, "msg": "sharepoint_notif_path non configuré dans Settings", + }) + continue + out = write_sharepoint_notification( + sp_base=sp_base, sp_route=ch["sp_route"], msg_type=msg_type, + server_name=server_name, intervenant=intervenant, + dynamic_to_email=ch.get("dynamic_to_email"), + ) + out["channel_name"] = ch["name"] + out["source"] = ch["source"] + results.append(out) + elif ch["mode"] == "webhook": + results.append({ + "channel_name": ch["name"], "source": ch["source"], + "ok": False, "msg": "Mode webhook non implémenté (OAuth requis)", + }) + else: + results.append({ + "channel_name": ch["name"], "source": ch["source"], + "ok": False, "msg": f"Mode inconnu: {ch['mode']}", + }) - return {"ok": False, "msg": f"Mode inconnu: {res['mode']}"} + n_ok = sum(1 for r in results if r.get("ok")) + return { + "ok": n_ok > 0, + "server": res["server"], + "results": results, + "summary": f"{n_ok}/{len(results)} canaux notifiés", + } # ─────────────────────────────────────────────────────────────── diff --git a/app/templates/settings.html b/app/templates/settings.html index 69126fb..97fc727 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -409,6 +409,8 @@ {% if tr.match_env_in %}env∈{{ tr.match_env_in|join(',') }}{% endif %} {% if tr.match_msg_type_in %}msg∈{{ tr.match_msg_type_in|join(',') }}{% endif %} {% if tr.match_hostname_pattern %}host~{{ tr.match_hostname_pattern }}{% endif %} + {% if tr.match_is_database_server is true %}DB only{% endif %} + {% if tr.match_is_database_server is false %}non-DB only{% endif %} {{ tr.channel_name or '(canal supprimé)' }} {{ 'Oui' if tr.is_active else 'Non' }} @@ -469,15 +471,23 @@ -
+
- +
+
+ + +
diff --git a/migrate_teams_rules_v2_20260506.sql b/migrate_teams_rules_v2_20260506.sql new file mode 100644 index 0000000..6bc217a --- /dev/null +++ b/migrate_teams_rules_v2_20260506.sql @@ -0,0 +1,39 @@ +-- Migration v2 routing Teams : multi-match (fan-out), serveurs DB, référents additionnels +-- - teams_channel_rules.match_is_database_server : nouvelle condition (matcher uniquement les DB) +-- - servers.is_database_server : flag manuel (default false) +-- - server_additional_referents : table additive pour multi-référents par serveur (cas DB Nadine+Cedric) +-- Le moteur de résolution passe en mode "toutes les règles qui matchent contribuent" (cf code service). +-- Idempotent. + +-- ─── 1) Condition is_database_server sur les règles ─────────── + +ALTER TABLE public.teams_channel_rules + ADD COLUMN IF NOT EXISTS match_is_database_server boolean; + -- NULL = pas de filtre ; true = ne matcher que les DB ; false = ne matcher que les non-DB + +-- ─── 2) Flag is_database_server sur servers ─────────────────── + +ALTER TABLE public.servers + ADD COLUMN IF NOT EXISTS is_database_server boolean NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS idx_servers_is_db + ON public.servers(is_database_server) + WHERE is_database_server = true; + +-- ─── 3) Référents additionnels (multi-référents) ────────────── + +CREATE TABLE IF NOT EXISTS public.server_additional_referents ( + server_id integer NOT NULL REFERENCES public.servers(id) ON DELETE CASCADE, + contact_id integer NOT NULL REFERENCES public.contacts(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'referent_technique', + -- 'referent_technique' (par défaut) | extensible (DBA, sécu, ...) + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (server_id, contact_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_server_additional_referents_contact + ON public.server_additional_referents(contact_id); + +-- ─── 4) GRANTs ──────────────────────────────────────────────── + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.server_additional_referents TO patchcenter;