"""Notification tracker management API routes.""" import logging from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from ..auth.dependencies import get_current_user from ..database.engine import get_session from ..database.models import ( EventLog, NotificationTarget, NotificationTracker, NotificationTrackerState, NotificationTrackerTarget, ServiceProvider, User, ) from ..services.scheduler import ( reschedule_immich_dispatch_jobs, schedule_tracker, unschedule_tracker, ) from .helpers import get_owned_entity from .notification_tracker_targets import _tt_response _LOGGER = logging.getLogger(__name__) router = APIRouter(prefix="/api/notification-trackers", tags=["notification-trackers"]) class NotificationTrackerCreate(BaseModel): provider_id: int name: str icon: str = "" collection_ids: list[str] = [] scan_interval: int = 60 adaptive_max_skip: int | None = None default_tracking_config_id: int | None = None default_template_config_id: int | None = None enabled: bool = True class NotificationTrackerUpdate(BaseModel): name: str | None = None icon: str | None = None collection_ids: list[str] | None = None scan_interval: int | None = None # int | None is ambiguous for partial updates — we can't distinguish # "clear the field" from "don't touch". Callers send this via # model_dump(exclude_unset=True), so an omitted key leaves the value # alone and an explicit null clears it back to the adaptive-off default. adaptive_max_skip: int | None = None default_tracking_config_id: int | None = None default_template_config_id: int | None = None enabled: bool | None = None @router.get("") async def list_notification_trackers( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): # Batched loader: pull trackers, then all their tracker-target links in # a single query, then the referenced targets in a single query. Avoids # the old 1 + N + N*M pattern that ran ~60 round-trips for 10 trackers. result = await session.exec( select(NotificationTracker).where(NotificationTracker.user_id == user.id) ) trackers = list(result.all()) if not trackers: return [] tracker_ids = [t.id for t in trackers] tt_result = await session.exec( select(NotificationTrackerTarget).where( NotificationTrackerTarget.tracker_id.in_(tracker_ids) ) ) tt_rows = list(tt_result.all()) target_ids = {tt.target_id for tt in tt_rows} targets_by_id: dict[int, NotificationTarget] = {} if target_ids: tgt_result = await session.exec( select(NotificationTarget).where(NotificationTarget.id.in_(target_ids)) ) targets_by_id = {t.id: t for t in tgt_result.all()} tts_by_tracker: dict[int, list[NotificationTrackerTarget]] = {} for tt in tt_rows: tts_by_tracker.setdefault(tt.tracker_id, []).append(tt) return [ _build_tracker_response(t, tts_by_tracker.get(t.id, []), targets_by_id) for t in trackers ] def _build_tracker_response( t: NotificationTracker, tts: list[NotificationTrackerTarget], targets_by_id: dict[int, NotificationTarget], ) -> dict: """In-memory assembler for a tracker + its pre-loaded links/targets.""" tracker_targets = [] for tt in tts: target = targets_by_id.get(tt.target_id) tracker_targets.append({ "id": tt.id, "tracker_id": tt.tracker_id, "target_id": tt.target_id, "target_name": target.name if target else None, "target_type": target.type if target else None, "target_icon": target.icon if target else None, "tracking_config_id": tt.tracking_config_id, "template_config_id": tt.template_config_id, "enabled": tt.enabled, "quiet_hours_start": tt.quiet_hours_start, "quiet_hours_end": tt.quiet_hours_end, "created_at": tt.created_at.isoformat(), }) return { "id": t.id, "name": t.name, "icon": t.icon, "provider_id": t.provider_id, "collection_ids": t.collection_ids, "scan_interval": t.scan_interval, "adaptive_max_skip": t.adaptive_max_skip, "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(), } @router.post("", status_code=status.HTTP_201_CREATED) async def create_notification_tracker( body: NotificationTrackerCreate, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): provider = await session.get(ServiceProvider, body.provider_id) if not provider or provider.user_id != user.id: raise HTTPException(status_code=404, detail="Provider not found") tracker = NotificationTracker(user_id=user.id, **body.model_dump()) session.add(tracker) await session.commit() await session.refresh(tracker) # Drop the cached enabled-trackers list so the next inbound event # (HA / webhook) sees the new tracker without waiting out the TTL. from ..services.event_dispatch import invalidate_tracker_cache invalidate_tracker_cache(tracker.provider_id) if tracker.enabled: await schedule_tracker( tracker.id, tracker.scan_interval, adaptive_max_skip=tracker.adaptive_max_skip, ) await reschedule_immich_dispatch_jobs() return await _tracker_response(session, tracker) @router.get("/{tracker_id}") async def get_notification_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): tracker = await _get_user_tracker(session, tracker_id, user.id) return await _tracker_response(session, tracker) @router.put("/{tracker_id}") async def update_notification_tracker( tracker_id: int, body: NotificationTrackerUpdate, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): tracker = await _get_user_tracker(session, tracker_id, user.id) for field, value in body.model_dump(exclude_unset=True).items(): setattr(tracker, field, value) session.add(tracker) await session.commit() await session.refresh(tracker) from ..services.event_dispatch import invalidate_tracker_cache invalidate_tracker_cache(tracker.provider_id) if tracker.enabled: await schedule_tracker( tracker.id, tracker.scan_interval, adaptive_max_skip=tracker.adaptive_max_skip, ) else: await unschedule_tracker(tracker.id) await reschedule_immich_dispatch_jobs() return await _tracker_response(session, tracker) @router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_notification_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Delete a tracker and its child rows in three bulk statements. The previous implementation issued one DELETE per child row plus one UPDATE per event_log row, which scaled linearly with the tracker's history (an old, busy tracker could hit thousands of round-trips). Bulk DELETE/UPDATE collapses that to three SQL statements regardless of size. """ from sqlalchemy import delete as sa_delete, update as sa_update tracker = await _get_user_tracker(session, tracker_id, user.id) # Junction rows — direct dependents of the tracker. await session.execute( sa_delete(NotificationTrackerTarget).where( NotificationTrackerTarget.tracker_id == tracker_id ) ) # Persisted scan state for this tracker. await session.execute( sa_delete(NotificationTrackerState).where( NotificationTrackerState.tracker_id == tracker_id ) ) # Preserve the audit trail in event_log; just null the back-reference # so the tracker row can be removed without an FK violation. await session.execute( sa_update(EventLog).where(EventLog.tracker_id == tracker_id).values(tracker_id=None) ) provider_id_for_cache = tracker.provider_id await session.delete(tracker) await session.commit() from ..services.event_dispatch import invalidate_tracker_cache invalidate_tracker_cache(provider_id_for_cache) await unschedule_tracker(tracker_id) await reschedule_immich_dispatch_jobs() @router.post("/{tracker_id}/trigger") async def trigger_notification_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): tracker = await _get_user_tracker(session, tracker_id, user.id) from ..services.watcher import check_tracker result = await check_tracker(tracker.id) return {"triggered": True, "result": result} @router.get("/{tracker_id}/history") async def notification_tracker_history( tracker_id: int, limit: int = Query(default=20, ge=1, le=500), user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): await _get_user_tracker(session, tracker_id, user.id) result = await session.exec( select(EventLog) .where(EventLog.tracker_id == tracker_id) .order_by(EventLog.created_at.desc()) .limit(limit) ) return [ { "id": e.id, "event_type": e.event_type, "collection_id": e.collection_id, "collection_name": e.collection_name, "details": e.details, "created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""), } for e in result.all() ] async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> dict: """Build tracker response with nested tracker_targets.""" result = await session.exec( select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == t.id) ) tracker_targets = [await _tt_response(session, tt) for tt in result.all()] return { "id": t.id, "name": t.name, "icon": t.icon, "provider_id": t.provider_id, "collection_ids": t.collection_ids, "scan_interval": t.scan_interval, "adaptive_max_skip": t.adaptive_max_skip, "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(), } async def _get_user_tracker( session: AsyncSession, tracker_id: int, user_id: int ) -> NotificationTracker: return await get_owned_entity( session, NotificationTracker, tracker_id, user_id, not_found_msg="Tracker not found", )