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
@@ -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":