feat: default tracker configs, email validation, expandable target links

- Tracker now has default_tracking_config_id and default_template_config_id
  that apply to all linked targets unless overridden per-target
- Dispatch falls back to tracker defaults when per-link configs are null
- Email bot creation validates SMTP connection before saving
- Email notifications sent as HTML (links render properly)
- Linked target items are expandable: collapsed shows config CrossLinks,
  expanded shows config selectors; action buttons always visible
- Fix email bot test button icon (mdiEmailSend → mdiSend)
- Fix target type icons in LinkedTargetsSection for all types
- Provider filter moved above search in sidebar
This commit is contained in:
2026-03-24 22:32:37 +03:00
parent d4cb388c74
commit 6e35926772
16 changed files with 246 additions and 102 deletions
@@ -55,6 +55,18 @@ async def create_email_bot(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
# Verify SMTP connection before saving
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
client = EmailClient(SmtpConfig(
host=body.smtp_host, port=body.smtp_port,
username=body.smtp_username, password=body.smtp_password,
from_address=body.email, from_name=body.name,
use_tls=body.smtp_use_tls,
))
result = await client.verify_connection()
if not result.get("success"):
raise HTTPException(status_code=400, detail=f"SMTP connection failed: {result.get('error', 'Unknown error')}")
bot = EmailBot(user_id=user.id, **body.model_dump())
session.add(bot)
await session.commit()
@@ -32,6 +32,8 @@ class NotificationTrackerCreate(BaseModel):
collection_ids: list[str] = []
scan_interval: int = 60
batch_duration: int = 0
default_tracking_config_id: int | None = None
default_template_config_id: int | None = None
enabled: bool = True
@@ -41,6 +43,8 @@ class NotificationTrackerUpdate(BaseModel):
collection_ids: list[str] | None = None
scan_interval: int | None = None
batch_duration: int | None = None
default_tracking_config_id: int | None = None
default_template_config_id: int | None = None
enabled: bool | None = None
@@ -190,6 +194,8 @@ async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> di
"collection_ids": t.collection_ids,
"scan_interval": t.scan_interval,
"batch_duration": t.batch_duration,
"default_tracking_config_id": t.default_tracking_config_id,
"default_template_config_id": t.default_template_config_id,
"enabled": t.enabled,
"tracker_targets": tracker_targets,
"created_at": t.created_at.isoformat(),
@@ -309,6 +309,8 @@ class NotificationTracker(SQLModel, table=True):
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
scan_interval: int = Field(default=60)
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
default_tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
default_template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
@@ -16,6 +16,7 @@ from ..database.models import (
EmailBot,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
TargetReceiver,
TelegramBot,
@@ -191,6 +192,11 @@ async def load_link_data(
tracker_id: ID of the tracker whose links to load.
check_quiet_hours: If True, skip links currently in quiet hours.
"""
# Load the tracker itself for default config IDs
tracker = await session.get(NotificationTracker, tracker_id)
default_tc_id = getattr(tracker, "default_tracking_config_id", None) if tracker else None
default_tmpl_id = getattr(tracker, "default_template_config_id", None) if tracker else None
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
@@ -198,35 +204,61 @@ async def load_link_data(
)
tracker_targets = tt_result.all()
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
if check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
continue
# Filter enabled links and quiet hours upfront
active_links = [
tt for tt in tracker_targets
if tt.enabled and not (check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end))
]
if not active_links:
return []
target = await session.get(NotificationTarget, tt.target_id)
# Batch-load targets
target_ids = list({tt.target_id for tt in active_links})
target_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
)
target_map = {t.id: t for t in target_result.all()}
# Batch-load tracking configs (per-link + tracker default)
tc_ids = list({tid for tid in
[tt.tracking_config_id for tt in active_links] + [default_tc_id]
if tid})
tc_map: dict[int, TrackingConfig] = {}
if tc_ids:
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
tc_map = {tc.id: tc for tc in tc_result.all()}
# Batch-load template configs (per-link + tracker default)
tmpl_ids = list({tid for tid in
[tt.template_config_id for tt in active_links] + [default_tmpl_id]
if tid})
tmpl_map: dict[int, TemplateConfig] = {}
if tmpl_ids:
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
tmpl_map = {tc.id: tc for tc in tmpl_result.all()}
# Batch-load template slots for all template configs
slots_by_config: dict[int, dict[str, dict[str, str]]] = {}
if tmpl_ids:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id.in_(tmpl_ids))
)
for s in slot_result.all():
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
link_data: list[dict[str, Any]] = []
for tt in active_links:
target = target_map.get(tt.target_id)
if not target:
continue
# 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)
template_config = None
template_slots: dict[str, dict[str, str]] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots: dict[str, dict[str, str]] = {}
for s in slot_result.all():
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
raw_slots.setdefault(event_key, {})[s.locale] = s.template
template_slots = raw_slots
# Per-link config overrides tracker defaults
tc_id = tt.tracking_config_id or default_tc_id
tmpl_id = tt.template_config_id or default_tmpl_id
tracking_config = tc_map.get(tc_id) if tc_id else None
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
template_slots = slots_by_config.get(template_config.id) if template_config else None
# Broadcast target: expand into child targets
if target.type == "broadcast":
@@ -104,14 +104,7 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid receivers"}
return _aggregate(results)
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
@@ -130,14 +123,7 @@ async def _send_webhook_broadcast(target: NotificationTarget, message: str, rece
client = WebhookClient(session, url, headers)
results.append(await client.send({"message": message, "event_type": "notification"}))
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid receivers"}
return _aggregate(results)
async def _send_email_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
@@ -179,18 +165,12 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
to_email=email,
subject="Notification from Notify Bridge",
body_text=message,
body_html=message,
to_name=recv.get("name", ""),
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid email receivers"}
return _aggregate(results)
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict: