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.
This commit is contained in:
Pierre & Lumière 2026-05-06 10:33:12 +02:00
parent edec1f7db5
commit 060af01db9
4 changed files with 240 additions and 76 deletions

View File

@ -145,6 +145,7 @@ def _build_context(db, user, saved=None):
SELECT r.id, r.priority, r.name, SELECT r.id, r.priority, r.name,
r.match_responsable_contact_id, r.match_application_domain, r.match_responsable_contact_id, r.match_application_domain,
r.match_env_in, r.match_msg_type_in, r.match_hostname_pattern, 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, r.channel_id, r.is_active, r.created_at,
tc.name AS channel_name, tc.name AS channel_name,
c.name AS responsable_name c.name AS responsable_name
@ -446,6 +447,16 @@ def _parse_csv_text_array(s: str):
return items or None 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) @router.post("/settings/teams-rule/add", response_class=HTMLResponse)
async def teams_rule_add(request: Request, db=Depends(get_db), async def teams_rule_add(request: Request, db=Depends(get_db),
tr_priority: int = Form(100), 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_application_domain: str = Form(""),
tr_match_env_in: str = Form(""), tr_match_env_in: str = Form(""),
tr_match_msg_type_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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
@ -467,8 +479,9 @@ async def teams_rule_add(request: Request, db=Depends(get_db),
INSERT INTO teams_channel_rules INSERT INTO teams_channel_rules
(priority, name, channel_id, (priority, name, channel_id,
match_responsable_contact_id, match_application_domain, 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,
VALUES (:p, :n, :ch, :rc, :dom, :env, :mt, :host) 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, "p": tr_priority, "n": tr_name.strip(), "ch": tr_channel_id,
"rc": resp_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), "env": _parse_csv_text_array(tr_match_env_in),
"mt": _parse_csv_text_array(tr_match_msg_type_in), "mt": _parse_csv_text_array(tr_match_msg_type_in),
"host": (tr_match_hostname_pattern.strip() or None), "host": (tr_match_hostname_pattern.strip() or None),
"db": _parse_db_filter(tr_match_is_database_server),
}) })
db.commit() db.commit()
ctx = _build_context(db, user, saved="teams") 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_env_in: str = Form(""),
tr_match_msg_type_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(""),
tr_is_active: str = Form("")): tr_is_active: str = Form("")):
user = get_current_user(request) user = get_current_user(request)
if not user: 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_application_domain=:dom,
match_env_in=:env, match_msg_type_in=:mt, match_env_in=:env, match_msg_type_in=:mt,
match_hostname_pattern=:host, match_hostname_pattern=:host,
match_is_database_server=:db,
is_active=:ia is_active=:ia
WHERE id=:id 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), "env": _parse_csv_text_array(tr_match_env_in),
"mt": _parse_csv_text_array(tr_match_msg_type_in), "mt": _parse_csv_text_array(tr_match_msg_type_in),
"host": (tr_match_hostname_pattern.strip() or None), "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, "ia": bool(tr_is_active), "id": tr_id,
}) })
db.commit() 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), async def teams_rule_test(request: Request, db=Depends(get_db),
server_id: int = Form(...), server_id: int = Form(...),
msg_type: str = Form("debut")): msg_type: str = Form("debut")):
"""Teste la résolution sans envoyer : retourne quel canal serait choisi pour """Teste la résolution sans envoyer : retourne la liste des canaux qui seraient
(server_id, msg_type) et pourquoi (source de la décision).""" notifiés pour (server_id, msg_type), avec la source (rule:<name> / reboot / default)."""
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
@ -553,8 +570,8 @@ async def teams_rule_test(request: Request, db=Depends(get_db),
perms = get_user_perms(db, user) perms = get_user_perms(db, user)
if not can_view(perms, "settings"): if not can_view(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.teams_service import resolve_channel_for_server from ..services.teams_service import resolve_channels_for_server
result = resolve_channel_for_server(db, server_id, msg_type) result = resolve_channels_for_server(db, server_id, msg_type)
return JSONResponse(result) return JSONResponse(result)

View File

@ -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]: def resolve_channels_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. """Résout la liste des canaux Teams cibles pour un serveur donné, selon le msg_type.
Algorithme : Algorithme :
1. Si msg_type='reboot' canal flaggé `is_reboot_channel=true` (priorité absolue) 1. Si msg_type='reboot' uniquement le canal `is_reboot_channel=true`
2. servers.teams_channel_id (override explicite) (PAS de fan-out ; les reboots vont à un seul endroit, le canal général DSI).
3. teams_channel_rules (ordre `priority ASC`, première règle qui matche 2. servers.teams_channel_id (override explicite) uniquement ce canal,
toutes les conditions actives) pas de fan-out non plus (override = "je sais ce que je veux").
4. teams_channels.is_default=true (fallback global) 3. Sinon : **fan-out** de toutes les règles actives qui matchent.
5. Sinon pas de notif 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 Conditions de match d'une règle :
`responsable_domaine_contact` du serveur pour préfixer le fichier `TO: <email>`. - 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 { Retourne {
ok: bool, ok: bool,
channel_id, name, mode, sp_route, webhook_url, is_dynamic_dm,
dynamic_to_email (str | None),
source ('reboot'|'server_override'|'rule:<name>'|'default'),
msg (si ok=False), 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 from sqlalchemy import text as sqlt
if not server_id: if not server_id:
return {"ok": False, "msg": "server_id manquant"} return {"ok": False, "msg": "server_id manquant", "channels": []}
def _channel_row(channel_id): def _channel_row(channel_id):
return db.execute(sqlt(""" 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 WHERE id = :id AND is_active = true
"""), {"id": channel_id}).fetchone() """), {"id": channel_id}).fetchone()
def _resolve_dynamic_to_email(server_row): def _contact_upn(contact_id):
"""Récupère teams_upn du responsable_domaine pour DM dynamique.""" if not contact_id:
if not server_row.responsable_domaine_contact_id:
return None return None
c = db.execute(sqlt(""" c = db.execute(sqlt("SELECT teams_upn FROM contacts WHERE id = :id"),
SELECT teams_upn FROM contacts WHERE id = :id {"id": contact_id}).fetchone()
"""), {"id": server_row.responsable_domaine_contact_id}).fetchone()
return c.teams_upn if c and c.teams_upn else None return c.teams_upn if c and c.teams_upn else None
# Récupère infos serveur (utilisé partout)
s = db.execute(sqlt(""" s = db.execute(sqlt("""
SELECT s.id, s.hostname, s.environnement, s.application_id, SELECT s.id, s.hostname, s.environnement, s.application_id,
s.responsable_domaine_contact_id, s.referent_technique_contact_id, s.responsable_domaine_contact_id, s.referent_technique_contact_id,
s.teams_channel_id AS server_channel_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 FROM servers s
LEFT JOIN applications a ON a.id = s.application_id LEFT JOIN applications a ON a.id = s.application_id
WHERE s.id = :sid WHERE s.id = :sid
"""), {"sid": server_id}).fetchone() """), {"sid": server_id}).fetchone()
if not s: 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): server_info = {
if not ch: "id": s.id, "hostname": s.hostname, "environnement": s.environnement,
return None "domain": s.app_domain, "is_database_server": s.is_db,
dyn_email = _resolve_dynamic_to_email(s) if ch.is_dynamic_dm else None "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 { return {
"ok": True,
"channel_id": ch.id, "name": ch.name, "channel_id": ch.id, "name": ch.name,
"mode": ch.mode, "sp_route": ch.sp_route, "mode": ch.mode, "sp_route": ch.sp_route,
"webhook_url": ch.webhook_url, "webhook_url": ch.webhook_url,
"is_dynamic_dm": ch.is_dynamic_dm, "is_dynamic_dm": ch.is_dynamic_dm,
"dynamic_to_email": dyn_email, "dynamic_to_email": dynamic_to_email,
"source": source, "source": source,
} }
# 1) Reboot → canal flaggé reboot (priorité absolue) # 1) Reboot → canal reboot uniquement
if msg_type == "reboot": if msg_type == "reboot":
ch = db.execute(sqlt(""" ch = db.execute(sqlt("""
SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm 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 ORDER BY id LIMIT 1
""")).fetchone() """)).fetchone()
if ch: if ch:
return _build_result(ch, "reboot") dyn = _contact_upn(s.responsable_domaine_contact_id) if ch.is_dynamic_dm else None
# Sinon on continue, peut-être un canal default routera 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: if s.server_channel_id:
ch = _channel_row(s.server_channel_id) ch = _channel_row(s.server_channel_id)
if ch: 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(""" rules = db.execute(sqlt("""
SELECT id, name, priority, channel_id, SELECT id, name, priority, channel_id,
match_responsable_contact_id, match_application_domain, 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 FROM teams_channel_rules
WHERE is_active = true WHERE is_active = true
ORDER BY priority ASC, id ASC 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_dom = (s.app_domain or "").strip()
server_host = (s.hostname or "").lower() server_host = (s.hostname or "").lower()
matched_channels = []
seen_channel_ids = set()
for r in rules: for r in rules:
if r.match_msg_type_in and msg_type not in r.match_msg_type_in: if r.match_msg_type_in and msg_type not in r.match_msg_type_in:
continue 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() pat = r.match_hostname_pattern.strip().lower()
if pat and pat not in server_host: if pat and pat not in server_host:
continue 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) ch = _channel_row(r.channel_id)
if ch: if not ch:
return _build_result(ch, f"rule:{r.name}") 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(""" ch = db.execute(sqlt("""
SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm
FROM teams_channels 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 ORDER BY id LIMIT 1
""")).fetchone() """)).fetchone()
if ch: 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]: 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. Mode 'sharepoint' : écrit un fichier .txt par canal (fan-out multi-recipient).
Pour mode='webhook' : POST sur webhook_url (non implémenté tant qu'OAuth pas en place). 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 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"): if not res.get("ok"):
return res return res
server_name = db.execute(__import__("sqlalchemy").text( from sqlalchemy import text as sqlt
"SELECT hostname FROM servers WHERE id = :id" server_name = db.execute(sqlt("SELECT hostname FROM servers WHERE id = :id"),
), {"id": server_id}).scalar() or "unknown" {"id": server_id}).scalar() or "unknown"
if res["mode"] == "sharepoint":
sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip() sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip()
results = []
for ch in res["channels"]:
if ch["mode"] == "sharepoint":
if not sp_base: if not sp_base:
return {"ok": False, "msg": "sharepoint_notif_path non configuré dans Settings", results.append({
"channel": res["name"]} "channel_name": ch["name"], "source": ch["source"],
"ok": False, "msg": "sharepoint_notif_path non configuré dans Settings",
})
continue
out = write_sharepoint_notification( out = write_sharepoint_notification(
sp_base=sp_base, sp_route=res["sp_route"], msg_type=msg_type, sp_base=sp_base, sp_route=ch["sp_route"], msg_type=msg_type,
server_name=server_name, intervenant=intervenant, server_name=server_name, intervenant=intervenant,
dynamic_to_email=res.get("dynamic_to_email"), dynamic_to_email=ch.get("dynamic_to_email"),
) )
out["channel"] = res["name"] out["channel_name"] = ch["name"]
out["source"] = res["source"] out["source"] = ch["source"]
return out 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']}",
})
if res["mode"] == "webhook": n_ok = sum(1 for r in results if r.get("ok"))
return {"ok": False, "msg": "Mode webhook non implémenté (OAuth Entra ID requis)", return {
"channel": res["name"]} "ok": n_ok > 0,
"server": res["server"],
return {"ok": False, "msg": f"Mode inconnu: {res['mode']}"} "results": results,
"summary": f"{n_ok}/{len(results)} canaux notifiés",
}
# ─────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────

View File

@ -409,6 +409,8 @@
{% if tr.match_env_in %}<span class="badge badge-gray">env∈{{ tr.match_env_in|join(',') }}</span>{% endif %} {% if tr.match_env_in %}<span class="badge badge-gray">env∈{{ tr.match_env_in|join(',') }}</span>{% endif %}
{% if tr.match_msg_type_in %}<span class="badge badge-orange">msg∈{{ tr.match_msg_type_in|join(',') }}</span>{% endif %} {% if tr.match_msg_type_in %}<span class="badge badge-orange">msg∈{{ tr.match_msg_type_in|join(',') }}</span>{% endif %}
{% if tr.match_hostname_pattern %}<span class="badge badge-gray">host~{{ tr.match_hostname_pattern }}</span>{% endif %} {% if tr.match_hostname_pattern %}<span class="badge badge-gray">host~{{ tr.match_hostname_pattern }}</span>{% endif %}
{% if tr.match_is_database_server is true %}<span class="badge badge-blue">DB only</span>{% endif %}
{% if tr.match_is_database_server is false %}<span class="badge badge-gray">non-DB only</span>{% endif %}
</td> </td>
<td class="p-2">{{ tr.channel_name or '(canal supprimé)' }}</td> <td class="p-2">{{ tr.channel_name or '(canal supprimé)' }}</td>
<td class="p-2 text-center"><span class="badge {% if tr.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if tr.is_active else 'Non' }}</span></td> <td class="p-2 text-center"><span class="badge {% if tr.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if tr.is_active else 'Non' }}</span></td>
@ -469,15 +471,23 @@
<input type="text" name="tr_match_env_in" placeholder="ex: Production" class="w-full"> <input type="text" name="tr_match_env_in" placeholder="ex: Production" class="w-full">
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-3 gap-3">
<div> <div>
<label class="text-xs text-gray-500">Match msg_type (CSV — debut,fin,reboot,annulation)</label> <label class="text-xs text-gray-500">Match msg_type (CSV — debut,fin,reboot,pre_intervention,post_intervention,annulation)</label>
<input type="text" name="tr_match_msg_type_in" placeholder="ex: debut,fin" class="w-full"> <input type="text" name="tr_match_msg_type_in" placeholder="ex: debut,fin" class="w-full">
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Match hostname (substring, insensible casse — ex: bst)</label> <label class="text-xs text-gray-500">Match hostname (substring, insensible casse — ex: bst)</label>
<input type="text" name="tr_match_hostname_pattern" placeholder="ex: bst" class="w-full"> <input type="text" name="tr_match_hostname_pattern" placeholder="ex: bst" class="w-full">
</div> </div>
<div>
<label class="text-xs text-gray-500">Match serveur DB</label>
<select name="tr_match_is_database_server" class="w-full">
<option value="">(pas filtré)</option>
<option value="true">DB uniquement</option>
<option value="false">non-DB uniquement</option>
</select>
</div>
</div> </div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter la règle</button> <button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter la règle</button>
</form> </form>

View File

@ -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;