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:
2026-03-20 14:26:20 +03:00
parent 9eec21a5b2
commit 91e5cd58e9
24 changed files with 3514 additions and 375 deletions
@@ -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",