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
@@ -111,8 +111,11 @@ async def list_targets(
if lang:
chat_languages[f"{bot_id}_{chat_id}"] = lang
# Build lookup for broadcast child target resolution
target_map = {t.id: t for t in targets}
return [
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages)
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages, target_map)
for t in targets
]
@@ -124,15 +127,24 @@ async def create_target(
session: AsyncSession = Depends(get_session),
):
"""Create a new notification target."""
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix")
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix", "broadcast")
if body.type not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type must be one of: {', '.join(valid_types)}",
)
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
# Broadcast-specific validation
if body.type == "broadcast":
child_ids = body.config.get("child_target_ids", [])
if not isinstance(child_ids, list):
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=None)
clean_config = {"child_target_ids": child_ids}
receiver_cfg: dict[str, Any] = {}
else:
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
target = NotificationTarget(
user_id=user.id,
@@ -190,34 +202,46 @@ async def update_target(
# If config is being updated, extract any delivery fields
if "config" in updates and updates["config"] is not None:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
if target.type == "broadcast":
child_ids = updates["config"].get("child_target_ids", [])
if not isinstance(child_ids, list):
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=target.id)
disabled_ids = updates["config"].get("disabled_child_ids", [])
updates["config"] = {"child_target_ids": child_ids, "disabled_child_ids": disabled_ids}
else:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
for field_name, value in updates.items():
setattr(target, field_name, value)
# Force SQLAlchemy to detect JSON column change
from sqlalchemy.orm.attributes import flag_modified
if "config" in updates:
flag_modified(target, "config")
session.add(target)
await session.commit()
await session.refresh(target)
@@ -257,11 +281,37 @@ async def test_target(
return result
async def _validate_broadcast_children(
session: AsyncSession,
child_ids: list[int],
user_id: int,
*,
exclude_target_id: int | None = None,
) -> None:
"""Validate broadcast child target IDs.
- All IDs must exist and belong to the user
- No broadcast targets allowed as children (prevents circular refs)
- Cannot reference itself
"""
if not child_ids:
return
if exclude_target_id and exclude_target_id in child_ids:
raise HTTPException(status_code=400, detail="A broadcast target cannot include itself")
for child_id in child_ids:
child = await session.get(NotificationTarget, child_id)
if not child or child.user_id != user_id:
raise HTTPException(status_code=400, detail=f"Child target {child_id} not found")
if child.type == "broadcast":
raise HTTPException(status_code=400, detail="A broadcast target cannot include another broadcast")
def _target_response(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
chat_languages: dict[str, str] | None = None,
target_map: dict[int, NotificationTarget] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
@@ -295,6 +345,14 @@ def _target_response(
recv_resp["chat_name"] = chat_names[key]
if chat_languages and key in chat_languages:
recv_resp["language_code"] = chat_languages[key]
# Attach child target summaries for broadcast targets
if target.type == "broadcast" and target_map:
child_ids = target.config.get("child_target_ids", [])
resp["child_targets"] = [
{"id": cid, "name": target_map[cid].name, "type": target_map[cid].type, "icon": target_map[cid].icon}
for cid in child_ids if cid in target_map
]
resp["receiver_count"] = len(resp["child_targets"])
return resp