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:
parent
edec1f7db5
commit
060af01db9
@ -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:<name> / 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)
|
||||
|
||||
|
||||
|
||||
@ -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: <email>`.
|
||||
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:<name>'|'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",
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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_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_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 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>
|
||||
@ -469,15 +471,23 @@
|
||||
<input type="text" name="tr_match_env_in" placeholder="ex: Production" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
</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>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter la règle</button>
|
||||
</form>
|
||||
|
||||
39
migrate_teams_rules_v2_20260506.sql
Normal file
39
migrate_teams_rules_v2_20260506.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user