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:
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user