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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user