fix: comprehensive API/UI review — 26 bug fixes and improvements
Backend: - Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs - Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data - Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified) - Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint) - Fix API key leak: only attach x-api-key header for internal provider URLs - Validate config ownership in tracker_targets create/update - Fix _response() double-emit of created_at in template/tracking configs - Add per-target-link test endpoints (test, test-periodic, test-memory) Frontend: - Fix orphaned provider on test exception in providers/new - Add submitting guard + disabled state to targets save button - Move test buttons from tracker card to per-target-link rows - Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations) - i18n for dashboard timeAgo and event type badges (EN + RU) - Add required attribute to chat select dropdown in targets - Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono - Standardize empty states with centered icon + text across all 6 list pages - Add stagger-children animation class to all list containers - Fix slide transition duration consistency (200ms everywhere) - Standardize border-radius to rounded-md across all form inputs - Fix providers/new page structure (h2 + mb-8 spacing) - Fix tracker card action row overflow (flex-wrap justify-end) - JinjaEditor dark mode reactivity (recreate editor on theme change) - Add aria-labels to mobile nav items - Make ConfirmModal confirm button label/icon configurable - Remove double error reporting on providers page - Add telegram bot edit functionality (name editing via PUT) - i18n for External Domain label on provider forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""Test notification sender."""
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..database.models import NotificationTarget
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget) -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram(target: NotificationTarget) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
chat_id=str(chat_id),
|
||||
caption="Test notification from Notify Bridge",
|
||||
)
|
||||
|
||||
|
||||
async def send_test_template_notification(
|
||||
target: NotificationTarget, slot: str, template_str: str
|
||||
) -> dict:
|
||||
"""Render a template slot with sample data and send it to a target."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from ..api.template_configs import _SAMPLE_CONTEXT
|
||||
|
||||
if not template_str:
|
||||
return await send_test_notification(target)
|
||||
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_str)
|
||||
message = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test template notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(chat_id=str(chat_id), caption=message)
|
||||
|
||||
|
||||
async def _test_webhook_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": "Test notification from Notify Bridge",
|
||||
"event_type": "test",
|
||||
})
|
||||
@@ -57,6 +57,34 @@ async def _load_tracker_jobs() -> None:
|
||||
_LOGGER.info("Scheduled tracker %d (%s) every %ds", tracker.id, tracker.name, tracker.scan_interval)
|
||||
|
||||
|
||||
async def schedule_tracker(tracker_id: int, interval: int) -> None:
|
||||
"""Add or update a scheduler job for a tracker."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.reschedule_job(job_id, trigger="interval", seconds=interval)
|
||||
_LOGGER.info("Rescheduled tracker %d every %ds", tracker_id, interval)
|
||||
else:
|
||||
scheduler.add_job(
|
||||
_poll_tracker,
|
||||
"interval",
|
||||
seconds=interval,
|
||||
id=job_id,
|
||||
args=[tracker_id],
|
||||
replace_existing=True,
|
||||
)
|
||||
_LOGGER.info("Scheduled tracker %d every %ds", tracker_id, interval)
|
||||
|
||||
|
||||
async def unschedule_tracker(tracker_id: int) -> None:
|
||||
"""Remove a scheduler job for a tracker."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
_LOGGER.info("Unscheduled tracker %d", tracker_id)
|
||||
|
||||
|
||||
async def _poll_tracker(tracker_id: int) -> None:
|
||||
"""Poll a tracker for changes."""
|
||||
from .watcher import check_tracker
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -22,11 +22,43 @@ from ..database.models import (
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
if not start or not end:
|
||||
return False
|
||||
try:
|
||||
now = datetime.now(timezone.utc).time()
|
||||
t_start = time.fromisoformat(start)
|
||||
t_end = time.fromisoformat(end)
|
||||
if t_start <= t_end:
|
||||
return t_start <= now <= t_end
|
||||
else:
|
||||
# Overnight window (e.g., 22:00 - 06:00)
|
||||
return now >= t_start or now <= t_end
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by the tracking config's flags."""
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
"assets_added": tc.track_assets_added,
|
||||
"assets_removed": tc.track_assets_removed,
|
||||
"collection_renamed": tc.track_collection_renamed,
|
||||
"collection_deleted": tc.track_collection_deleted,
|
||||
"sharing_changed": tc.track_sharing_changed,
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
"""Poll a tracker's provider for changes and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
@@ -49,27 +81,44 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
state_dict: dict[str, Any] = {}
|
||||
for s in states:
|
||||
state_dict[s.collection_id] = {
|
||||
"name": "",
|
||||
"name": s.collection_name or "",
|
||||
"asset_ids": s.asset_ids,
|
||||
"pending_asset_ids": s.pending_asset_ids,
|
||||
"shared": False,
|
||||
"shared": bool(s.shared),
|
||||
}
|
||||
|
||||
# Load targets
|
||||
targets_db: list[NotificationTarget] = []
|
||||
for tid in (tracker.target_ids or []):
|
||||
t = await session.get(NotificationTarget, tid)
|
||||
if t:
|
||||
targets_db.append(t)
|
||||
# Load tracker-target links (replaces old target_ids JSON array)
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
tracker_targets = tt_result.all()
|
||||
|
||||
# Load template configs for targets
|
||||
template_configs: dict[int, TemplateConfig | None] = {}
|
||||
for t in targets_db:
|
||||
if t.template_config_id:
|
||||
tc = await session.get(TemplateConfig, t.template_config_id)
|
||||
template_configs[t.id] = tc
|
||||
else:
|
||||
template_configs[t.id] = None
|
||||
# For each link, load target + tracking config + template config
|
||||
link_data: list[dict[str, Any]] = []
|
||||
for tt in tracker_targets:
|
||||
if not tt.enabled:
|
||||
continue
|
||||
if _in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
|
||||
continue
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
tracking_config = None
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
|
||||
link_data.append({
|
||||
"target_type": target.type,
|
||||
"target_config": dict(target.config),
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
})
|
||||
|
||||
# Snapshot the data we need
|
||||
provider_type = provider.type
|
||||
@@ -110,11 +159,15 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
if existing:
|
||||
existing.asset_ids = cstate.get("asset_ids", [])
|
||||
existing.pending_asset_ids = cstate.get("pending_asset_ids", [])
|
||||
existing.collection_name = cstate.get("name", "")
|
||||
existing.shared = cstate.get("shared", False)
|
||||
session.add(existing)
|
||||
else:
|
||||
new_ts = TrackerState(
|
||||
tracker_id=tracker_id,
|
||||
collection_id=cid,
|
||||
collection_name=cstate.get("name", ""),
|
||||
shared=cstate.get("shared", False),
|
||||
asset_ids=cstate.get("asset_ids", []),
|
||||
pending_asset_ids=cstate.get("pending_asset_ids", []),
|
||||
)
|
||||
@@ -136,28 +189,61 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Dispatch notifications
|
||||
if events and targets_db:
|
||||
# Dispatch notifications — per-link config resolution
|
||||
# Filter out empty events (e.g. assets_added with 0 added)
|
||||
events = [
|
||||
e for e in events
|
||||
if not (e.event_type.value == "assets_added" and e.added_count == 0)
|
||||
and not (e.event_type.value == "assets_removed" and e.removed_count == 0)
|
||||
]
|
||||
|
||||
_LOGGER.info(
|
||||
"Tracker %d: %d events after filter, %d links",
|
||||
tracker_id, len(events), len(link_data),
|
||||
)
|
||||
|
||||
if events and link_data:
|
||||
dispatcher = NotificationDispatcher()
|
||||
for event in events:
|
||||
_LOGGER.info(
|
||||
"Dispatching event %s for %s (added=%d removed=%d)",
|
||||
event.event_type.value, event.collection_name,
|
||||
event.added_count, event.removed_count,
|
||||
)
|
||||
target_configs = []
|
||||
for t in targets_db:
|
||||
tc = template_configs.get(t.id)
|
||||
for ld in link_data:
|
||||
# Apply per-link event filtering from tracking config
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not _event_allowed_by_config(event, tc):
|
||||
_LOGGER.info(" Skipped by tracking config filter")
|
||||
continue
|
||||
|
||||
# Build template slots from template config
|
||||
tmpl = ld["template_config"]
|
||||
slots = None
|
||||
if tc:
|
||||
if tmpl:
|
||||
slots = {
|
||||
"assets_added": tc.message_assets_added,
|
||||
"assets_removed": tc.message_assets_removed,
|
||||
"collection_renamed": tc.message_collection_renamed,
|
||||
"collection_deleted": tc.message_collection_deleted,
|
||||
"sharing_changed": tc.message_sharing_changed,
|
||||
"assets_added": tmpl.message_assets_added,
|
||||
"assets_removed": tmpl.message_assets_removed,
|
||||
"collection_renamed": tmpl.message_collection_renamed,
|
||||
"collection_deleted": tmpl.message_collection_deleted,
|
||||
"sharing_changed": tmpl.message_sharing_changed,
|
||||
}
|
||||
target_configs.append(TargetConfig(
|
||||
type=t.type,
|
||||
config=t.config,
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=slots,
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
))
|
||||
await dispatcher.dispatch(event, target_configs)
|
||||
|
||||
if target_configs:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
if r.get("success"):
|
||||
_LOGGER.info(" Notification sent successfully")
|
||||
else:
|
||||
_LOGGER.error(" Notification failed: %s", r.get("error", "unknown"))
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
||||
Reference in New Issue
Block a user