feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events

Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
This commit is contained in:
2026-04-22 01:13:11 +03:00
parent b5ffab7ece
commit a7a2b4efa4
57 changed files with 2452 additions and 335 deletions
@@ -3,12 +3,14 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import delete as sa_delete
from sqlmodel import func, 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 (
Action,
CommandConfig,
CommandTemplateConfig,
CommandTracker,
@@ -54,12 +56,10 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
# Build events query with filters
events_query = (
select(EventLog)
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
.where(NotificationTracker.user_id == user.id)
)
# Build events query with filters. EventLog.user_id is the owner column;
# action events (event_type starts with "action_") have tracker_id NULL but
# user_id set, so we filter by user_id directly.
events_query = select(EventLog).where(EventLog.user_id == user.id)
if event_type:
events_query = events_query.where(EventLog.event_type == event_type)
@@ -69,6 +69,7 @@ async def get_status(
events_query = events_query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search)
)
@@ -84,6 +85,65 @@ async def get_status(
events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query)
event_rows = recent_events.all()
# Resolve live tracker names from FK (fall back to stored snapshot when deleted)
tracker_ids = {e.tracker_id for e in event_rows if e.tracker_id is not None}
tracker_name_map: dict[int, str] = {}
if tracker_ids:
tracker_rows = (await session.exec(
select(NotificationTracker.id, NotificationTracker.name).where(
NotificationTracker.id.in_(tracker_ids)
)
)).all()
tracker_name_map = {tid: tname for tid, tname in tracker_rows}
# Resolve live provider names similarly
provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None}
provider_name_map: dict[int, str] = {}
if provider_ids:
provider_rows = (await session.exec(
select(ServiceProvider.id, ServiceProvider.name).where(
ServiceProvider.id.in_(provider_ids)
)
)).all()
provider_name_map = {pid: pname for pid, pname in provider_rows}
# Resolve live action names so renames are reflected; fall back to snapshot.
action_ids = {e.action_id for e in event_rows if e.action_id is not None}
action_name_map: dict[int, str] = {}
if action_ids:
action_rows = (await session.exec(
select(Action.id, Action.name).where(Action.id.in_(action_ids))
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
return f"(deleted) {e.tracker_name}" if e.tracker_name else "(deleted)"
def _display_provider_name(e: EventLog) -> str:
if e.provider_id is not None and e.provider_id in provider_name_map:
return provider_name_map[e.provider_id]
return e.provider_name or ""
def _display_action_name(e: EventLog) -> str:
if e.action_id is not None and e.action_id in action_name_map:
return action_name_map[e.action_id]
if e.action_name:
return f"(deleted) {e.action_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
return e.collection_name
return {
"providers": providers_count,
@@ -94,19 +154,43 @@ async def get_status(
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"tracker_name": e.tracker_name or "",
"provider_name": e.provider_name or "",
"collection_name": _display_subject(e),
"tracker_name": _display_tracker_name(e),
"action_id": e.action_id,
"action_name": _display_action_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {},
}
for e in recent_events.all()
for e in event_rows
],
}
@router.delete("/events")
async def clear_events(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
older_than_days: int | None = Query(None, ge=0),
):
"""Delete all event log entries for the current user.
Optionally keep events newer than `older_than_days` days.
"""
stmt = sa_delete(EventLog).where(EventLog.user_id == user.id)
if older_than_days is not None:
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
stmt = stmt.where(EventLog.created_at < cutoff)
# Use session.execute() for DELETE (consistent with other endpoints and
# avoids sqlmodel wrapping a CursorResult that may drop rowcount).
result = await session.execute(stmt)
await session.commit()
return {"deleted": result.rowcount or 0}
@router.get("/counts")
async def get_nav_counts(
user: User = Depends(get_current_user),
@@ -192,8 +276,7 @@ async def get_event_chart(
EventLog.event_type,
func.count().label("total"),
)
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
.where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
)
if event_type:
@@ -204,6 +287,7 @@ async def get_event_chart(
query = query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search)
)