feat: broadcast notification target + UX improvements

Add broadcast target type that fans out notifications to multiple
child targets. Dispatch expands broadcast into children in
load_link_data() — dispatcher stays unaware. Children can be
toggled on/off via disabled_child_ids in config.

Also: dashboard provider card smaller font for names, scroll-to-form
on target edit, broadcast nav tab with counter, flag_modified fix
for JSON column updates, CLAUDE.md nav tree docs.
This commit is contained in:
2026-03-24 15:15:41 +03:00
parent 8cb836e16c
commit d8ecb60073
13 changed files with 327 additions and 102 deletions
@@ -96,6 +96,88 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
return flag_map.get(event_type, True)
async def _resolve_target(
session: AsyncSession,
target: NotificationTarget,
) -> dict[str, Any]:
"""Resolve a single target into dispatch-ready data (config + receivers + credentials).
Returns a dict with target_type, target_config, and receivers.
Does NOT include tracking_config or template_slots — those come from the tracker link.
"""
# Load receivers as typed Receiver objects
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
recv_rows = recv_result.all()
# For Telegram targets, resolve locale from TelegramChat
chat_locale_map: dict[str, str] = {}
if target.type == "telegram":
bot_id = target.config.get("bot_id")
if bot_id:
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
if chat_ids:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id.in_(chat_ids),
)
)
for chat in chat_result.all():
resolved = (
getattr(chat, 'language_override', '') or
getattr(chat, 'language_code', '') or ''
)
if resolved:
chat_locale_map[chat.chat_id] = resolved[:2].lower()
receivers: list[Receiver] = []
for r in recv_rows:
explicit_locale = getattr(r, 'locale', '') or ''
locale = explicit_locale
if not locale and target.type == "telegram":
chat_id = str(r.config.get("chat_id", ""))
locale = chat_locale_map.get(chat_id, "")
receivers.append(build_receiver(target.type, dict(r.config), locale))
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
return {
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
}
async def load_link_data(
session: AsyncSession,
tracker_id: int,
@@ -127,45 +209,7 @@ async def load_link_data(
if not target:
continue
# Load receivers as typed Receiver objects
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
recv_rows = recv_result.all()
# For Telegram targets, resolve locale from TelegramChat
chat_locale_map: dict[str, str] = {}
if target.type == "telegram":
bot_id = target.config.get("bot_id")
if bot_id:
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
if chat_ids:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id.in_(chat_ids),
)
)
for chat in chat_result.all():
resolved = (
getattr(chat, 'language_override', '') or
getattr(chat, 'language_code', '') or ''
)
if resolved:
chat_locale_map[chat.chat_id] = resolved[:2].lower()
receivers: list[Receiver] = []
for r in recv_rows:
explicit_locale = getattr(r, 'locale', '') or ''
locale = explicit_locale
if not locale and target.type == "telegram":
chat_id = str(r.config.get("chat_id", ""))
locale = chat_locale_map.get(chat_id, "")
receivers.append(build_receiver(target.type, dict(r.config), locale))
# Load tracking config and template slots (shared across broadcast children)
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
@@ -184,37 +228,29 @@ async def load_link_data(
raw_slots.setdefault(event_key, {})[s.locale] = s.template
template_slots = raw_slots
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
# Broadcast target: expand into child targets
if target.type == "broadcast":
child_ids = target.config.get("child_target_ids", [])
disabled_ids = set(target.config.get("disabled_child_ids", []))
for child_id in child_ids:
if child_id in disabled_ids:
continue
child_target = await session.get(NotificationTarget, child_id)
if not child_target or child_target.type == "broadcast":
continue
resolved = await _resolve_target(session, child_target)
link_data.append({
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
continue
# Regular target
resolved = await _resolve_target(session, target)
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
@@ -309,11 +309,35 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message."""
"""Send a simple test message. For broadcast targets, fans out to all children."""
if target.type == "broadcast":
return await _send_broadcast_test(target, locale)
message = _get_test_message(locale, target.type)
return await send_to_target(target, message)
async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
"""Send test notifications to all child targets of a broadcast target."""
child_ids = target.config.get("child_target_ids", [])
if not child_ids:
return {"success": False, "error": "No child targets configured"}
disabled_ids = set(target.config.get("disabled_child_ids", []))
engine = get_engine()
results: list[dict] = []
async with AsyncSession(engine) as session:
for child_id in child_ids:
if child_id in disabled_ids:
continue
child = await session.get(NotificationTarget, child_id)
if not child or child.type == "broadcast":
continue
message = _get_test_message(locale, child.type)
results.append(await send_to_target(child, message))
return _aggregate(results)
async def send_test_template_notification(
target: NotificationTarget, slot: str, template_str: str
) -> dict: