Files
notify-bridge/packages/server/src/notify_bridge_server/services/test_dispatch.py
T
alexei.dolgolyov b803d004e1 refactor: comprehensive codebase review — security, performance, quality, UX
Security:
- Fix NUT protocol command injection (validate names against safe regex)
- Enable Jinja2 autoescape=True to prevent HTML injection via external data
- Add WebhookProviderConfig validation model

Performance:
- Shared aiohttp.ClientSession singleton (replaces 40+ per-request sessions)
- Fix 4 N+1 queries with batch IN loads (poller, scheduler, memory, broadcast)
- asyncio.gather for Gitea commands and notification dispatcher
- Add DB indexes on NotificationTrackerState.tracker_id, CommandTrackerListener
- LRU cache for compiled Jinja2 templates
- Daily EventLog cleanup job (90-day retention)
- 30s HTTP timeout on all external calls
- GROUP BY for target type counts (replaces 7 sequential queries)

Code quality:
- Extract get_owned_entity() helper (replaces 11 duplicate functions)
- Extract slot_helpers.py (load_slots, save_slots, render_template_preview)
- Extract command_utils.py (tracker lookup, last event, collection IDs)
- Extract http_session.py (shared session lifecycle)
- Provider connection validation dedup (3x → 1 helper)
- Command dispatch tables replacing if/elif chains
- Album+links fetch helper (fetch_albums_with_links)
- Provider dispatch polymorphism (list_provider_collections)
- Immutable _enrich_assets (no longer mutates in-place)
- Fix _format_assets return type + handler unpacking

Frontend:
- Fix 18+ hardcoded English strings → t() with new i18n keys (en + ru)
- Mobile "More" nav panel with provider filter and search
- Shared Button.svelte component (4 variants, 2 sizes)
- Shared ErrorBanner.svelte component (8 pages updated)
- SvelteKit goto() replacing window.location.href
- Dashboard grid fixed for 4 cards, paginator opacity consistency

Functionality:
- max_instances=1 on scheduler jobs (prevents duplicate events)
- Webhook provider in watcher (prevents error spam)
- Fix stale SQLModel reference in poller
- Gitea get_repo() direct API call
2026-03-28 13:22:26 +03:00

315 lines
11 KiB
Python

"""Test dispatch — manual trigger through the real NotificationDispatcher.
No separate logic — just builds a ServiceEvent + TargetConfig from DB
objects and dispatches through the same path the watcher uses.
"""
import logging
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.providers.base import ServiceProviderType
from ..database.models import (
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
from .dispatch_helpers import _resolve_target
from .watcher import _get_telegram_caches
_LOGGER = logging.getLogger(__name__)
# Maps test_type → DB template slot name
_TEST_TYPE_SLOT_MAP = {
"periodic": "periodic_summary_message",
"scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
async def dispatch_test_notification(
*,
session: AsyncSession,
tracker: NotificationTracker,
tt: NotificationTrackerTarget,
target: NotificationTarget,
test_type: str,
locale: str = "en",
) -> dict[str, Any]:
"""Dispatch a test notification through the real NotificationDispatcher."""
# Load provider
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
return {"success": False, "error": "Provider not found"}
provider_config = dict(provider.config)
collection_ids = list(tracker.collection_ids or [])
# Load tracking config
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
# Load template slots keyed by EventType.SCHEDULED_MESSAGE.value
template_config = None
template_slots: dict[str, dict[str, str]] | None = None
slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type)
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,
TemplateSlot.slot_name == slot_name,
)
)
locale_map: dict[str, str] = {}
for s in slot_result.all():
locale_map[s.locale] = s.template
if locale_map:
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
# Resolve target config + receivers (same as watcher)
resolved = await _resolve_target(session, target)
target_cfg = TargetConfig(
type=resolved["target_type"],
config=resolved["target_config"],
template_slots=template_slots,
locale=locale,
date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC",
date_only_format=template_config.date_only_format if template_config and template_config.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
receivers=resolved["receivers"],
)
# Fetch assets and build event
event = await _build_event(
provider_type=provider.type,
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
tracker_filters=dict(tracker.filters) if tracker.filters else {},
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
if event is None:
return {"success": False, "error": "No data returned from provider"}
if not event.added_assets and test_type in ("scheduled", "memory"):
return {"success": False, "error": "No matching assets found" + (" for today" if test_type == "memory" else "")}
# Dispatch through the real NotificationDispatcher
url_cache, asset_cache = await _get_telegram_caches()
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
results = await dispatcher.dispatch(event, [target_cfg])
if not results:
return {"success": False, "error": "No dispatch results"}
return results[0]
async def _build_event(
*,
provider_type: str,
provider_config: dict,
provider_name: str,
tracker_name: str,
tracker_filters: dict,
collection_ids: list[str],
test_type: str,
tracking_config: TrackingConfig | None = None,
) -> ServiceEvent | None:
"""Build a ServiceEvent with real provider data."""
from datetime import datetime, timezone
if provider_type == "immich":
return await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
elif provider_type == "scheduler":
from notify_bridge_core.providers.scheduler import SchedulerServiceProvider
custom_vars = tracker_filters.get("custom_variables", {})
sched = SchedulerServiceProvider(
name=provider_name,
tracker_name=tracker_name,
custom_variables=custom_vars,
)
events, _ = await sched.poll(collection_ids, {})
return events[0] if events else None
return None
async def _build_immich_event(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
test_type: str,
tracking_config: TrackingConfig | None = None,
) -> ServiceEvent | None:
"""Build an Immich scheduled/memory event using shared core utilities."""
from datetime import datetime, timezone
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.immich.asset_utils import collect_scheduled_assets
from notify_bridge_core.providers.immich.models import ImmichAlbumData, SharedLinkInfo
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
prefix = "memory" if test_type == "memory" else "scheduled"
limit = getattr(tracking_config, f"{prefix}_limit", 10) if tracking_config else 10
asset_type = getattr(tracking_config, f"{prefix}_asset_type", "all") if tracking_config else "all"
favorite_only = getattr(tracking_config, f"{prefix}_favorite_only", False) if tracking_config else False
min_rating = getattr(tracking_config, f"{prefix}_min_rating", 0) if tracking_config else 0
memory_source = getattr(tracking_config, "memory_source", "albums") if tracking_config else "albums"
is_memory = test_type == "memory"
from .http_session import get_http_session
http_session = await get_http_session()
immich = ImmichServiceProvider(
http_session,
provider_config.get("url", ""),
provider_config.get("api_key", ""),
provider_config.get("external_domain"),
provider_name,
)
if not await immich.connect():
return None
# Native Immich memories API path
if is_memory and memory_source == "native":
return await _build_native_memory_event(
immich, ext_domain, provider_name, tracker_name,
collection_ids, limit, asset_type, favorite_only, min_rating,
)
# Album-based path: use shared collect_scheduled_assets
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if album:
albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id)
assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain,
limit=limit,
asset_type=asset_type,
favorite_only=favorite_only,
min_rating=min_rating,
is_memory=is_memory,
)
first_col = collections_extra[0] if collections_extra else {}
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=first_col.get("name", tracker_name),
timestamp=datetime.now(timezone.utc),
added_assets=assets,
added_count=len(assets),
extra={
"collections": collections_extra,
"albums": collections_extra,
**(first_col if first_col else {}),
},
)
async def _build_native_memory_event(
immich,
ext_domain: str,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
limit: int,
asset_type: str,
favorite_only: bool,
min_rating: int,
) -> ServiceEvent | None:
"""Build event from Immich native memories API."""
import random
from datetime import datetime, timezone
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.providers.immich.asset_utils import filter_assets
from notify_bridge_core.providers.immich.models import ImmichAssetInfo
memories = await immich.client.get_memories()
tracked_ids = set(collection_ids) if collection_ids else None
# Collect raw assets, convert to ImmichAssetInfo for unified filtering
raw_assets: list[ImmichAssetInfo] = []
year_map: dict[str, int | None] = {} # asset_id → memory year
for mem in memories:
mem_year = mem.get("data", {}).get("year")
for raw in mem.get("assets", []):
asset_id = raw.get("id", "")
if tracked_ids:
asset_albums = raw.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
asset = ImmichAssetInfo.from_api_response(raw)
if not asset.is_processed:
continue
raw_assets.append(asset)
year_map[asset_id] = mem_year
# Apply standard filters (no memory_date — native API already filters by date)
filtered = filter_assets(
raw_assets,
favorite_only=favorite_only,
min_rating=min_rating,
asset_type=asset_type,
)
# Random sample
if len(filtered) > limit:
selected = random.sample(filtered, limit)
else:
random.shuffle(filtered)
selected = filtered
from notify_bridge_core.providers.immich.asset_utils import asset_to_media
all_assets = []
for asset in selected:
media = asset_to_media(asset, ext_domain)
media.extra["year"] = year_map.get(asset.id)
all_assets.append(media)
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=tracker_name,
timestamp=datetime.now(timezone.utc),
added_assets=all_assets,
added_count=len(all_assets),
extra={
"collections": [],
"albums": [],
},
)