feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
@@ -216,6 +216,73 @@ async def list_collections(
return []
@router.get("/{provider_id}/albums/{album_id}/shared-links")
async def get_album_shared_links(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Check shared links for a specific album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
links = await immich.client.get_shared_links(album_id)
return [
{
"id": link.id,
"key": link.key,
"has_password": link.has_password,
"is_expired": link.is_expired,
"is_accessible": link.is_accessible,
}
for link in links
]
return []
@router.post("/{provider_id}/albums/{album_id}/shared-links")
async def create_album_shared_link(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Auto-create a public shared link for an album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
success = await immich.client.create_shared_link(album_id)
if success:
return {"success": True}
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Failed to create shared link")
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Provider type does not support shared links")
def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
@@ -1,6 +1,6 @@
"""Status/dashboard API route."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
async def get_status(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
# Event filtering
event_type: str | None = Query(None),
provider_id: int | None = Query(None),
search: str | None = Query(None),
sort: str = Query("newest"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Get dashboard status data."""
"""Get dashboard status data with enriched events."""
providers_count = (await session.exec(
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
)).one()
@@ -31,24 +38,53 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
recent_events = await session.exec(
# Build events query with filters
events_query = (
select(EventLog)
.join(Tracker, EventLog.tracker_id == Tracker.id)
.where(Tracker.user_id == user.id)
.order_by(EventLog.created_at.desc())
.limit(10)
)
if event_type:
events_query = events_query.where(EventLog.event_type == event_type)
if provider_id is not None:
events_query = events_query.where(EventLog.provider_id == provider_id)
if search:
events_query = events_query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.provider_name.contains(search)
)
# Count total matching events (for pagination)
count_query = select(func.count()).select_from(events_query.subquery())
total_events = (await session.exec(count_query)).one()
# Sort
if sort == "oldest":
events_query = events_query.order_by(EventLog.created_at.asc())
else:
events_query = events_query.order_by(EventLog.created_at.desc())
events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query)
return {
"providers": providers_count,
"trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count,
"total_events": total_events,
"recent_events": [
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"created_at": e.created_at.isoformat(),
"tracker_name": e.tracker_name or "",
"provider_name": e.provider_name or "",
"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()
],
@@ -1,6 +1,6 @@
"""Notification target management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -8,7 +8,7 @@ from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
name: str
icon: str = ""
config: dict[str, Any] = {}
template_config_id: int | None = None
class TargetUpdate(BaseModel):
name: str | None = None
icon: str | None = None
config: dict[str, Any] | None = None
template_config_id: int | None = None
@router.get("")
@@ -37,18 +35,26 @@ async def list_targets(
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return [
{
"id": t.id,
"type": t.type,
"name": t.name,
"icon": t.icon,
"config": _safe_config(t),
"template_config_id": t.template_config_id,
"created_at": t.created_at.isoformat(),
}
for t in result.all()
]
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [_target_response(t, chat_names) for t in targets]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -69,7 +75,6 @@ async def create_target(
name=body.name,
icon=body.icon,
config=body.config,
template_config_id=body.template_config_id,
)
session.add(target)
await session.commit()
@@ -85,14 +90,7 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"template_config_id": target.template_config_id,
}
return _target_response(target)
@router.put("/{target_id}")
@@ -104,14 +102,8 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
if body.name is not None:
target.name = body.name
if body.icon is not None:
target.icon = body.icon
if body.config is not None:
target.config = body.config
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -124,8 +116,14 @@ async def delete_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a notification target."""
"""Delete a notification target and its tracker links."""
target = await _get_user_target(session, target_id, user.id)
# Delete associated tracker-target links
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
)
for tt in result.all():
await session.delete(tt)
await session.delete(target)
await session.commit()
@@ -133,16 +131,36 @@ async def delete_target(
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a target."""
target = await _get_user_target(session, target_id, user.id)
from ..services.notifier import send_test_notification
result = await send_test_notification(target)
result = await send_test_notification(target, locale=locale)
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
resp = {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
return resp
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
@@ -1,6 +1,6 @@
"""Telegram bot management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
class BotUpdate(BaseModel):
name: str | None = None
commands_config: dict | None = None
icon: str | None = None
@router.get("")
@@ -69,12 +69,12 @@ async def update_bot(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name and/or commands config."""
"""Update a bot's display name and icon."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
if body.commands_config is not None:
bot.commands_config = body.commands_config
if body.icon is not None:
bot.icon = body.icon
session.add(bot)
await session.commit()
await session.refresh(bot)
@@ -173,6 +173,37 @@ async def discover_chats(
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/chats/{chat_id}/test")
async def test_chat(
bot_id: int,
chat_id: str,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot."""
from ..services.notifier import _get_test_message
bot = await _get_user_bot(session, bot_id, user.id)
message = _get_test_message(locale, "telegram")
try:
async with aiohttp.ClientSession() as http:
async with http.post(
f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
},
) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"icon": b.icon,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"commands_config": b.commands_config,
"created_at": b.created_at.isoformat(),
}
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
"state": "Ile-de-France",
"country": "France",
"url": "https://immich.example.com/photos/abc123",
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
}
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
"is_favorite": False,
"rating": None,
"photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_COLLECTION = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"public_url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
"new_name": "New Album",
"old_shared": False,
"new_shared": True,
# Public share URLs (may be empty if no shared link exists)
"public_url": "https://immich.example.com/share/abc123",
"protected_url": "",
"album_url": "https://immich.example.com/albums/b2eeeaa4",
# Common date/location (set when all assets share the same value)
"common_date": "19.03.2026",
"common_location": "Paris, France",
# Date format strings (from template config)
"date_format": "%d.%m.%Y, %H:%M UTC",
"date_only_format": "%d.%m.%Y",
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
"date": "2026-03-19",
"photo_count": 30,
"video_count": 5,
"owner": "Alice",
}
@@ -106,6 +123,7 @@ class TemplateConfigCreate(BaseModel):
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
date_only_format: str | None = None
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
@@ -142,10 +160,15 @@ async def get_template_variables():
"collection_id": "Collection ID (UUID)",
"collection_name": "Collection name",
"collection_url": "Public share URL (empty if not shared)",
"public_url": "Public share link URL (empty if no link exists)",
"protected_url": "Password-protected share link URL (empty if none)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"people": "Detected people names (list, use {{ people | join(', ') }})",
"shared": "Whether collection is shared (boolean)",
"photo_count": "Total photo count in album",
"video_count": "Total video count in album",
"owner": "Album owner name",
"target_type": "Target type: 'telegram' or 'webhook'",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
@@ -177,6 +200,7 @@ async def get_template_variables():
"city": "City name",
"state": "State/region name",
"country": "Country name",
"public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
@@ -185,6 +209,7 @@ async def get_template_variables():
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
}
@@ -196,7 +221,12 @@ async def get_template_variables():
return {
"message_assets_added": {
"description": "Notification when new assets are added to a collection",
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
"variables": {
**event_vars,
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
},
"asset_fields": asset_fields,
},
"message_assets_removed": {
@@ -308,6 +338,8 @@ async def preview_config(
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # "telegram" or "webhook"
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@router.post("/preview-raw")
@@ -334,7 +366,14 @@ async def preview_raw(
# Pass 2: render with strict undefined to catch unknown variables
try:
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**ctx)
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -152,6 +152,7 @@ async def delete_tracker_target(
async def test_tracker_target(
tracker_id: int,
tracker_target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
@@ -166,7 +167,7 @@ async def test_tracker_target(
raise HTTPException(status_code=404, detail="Target not found")
from ..services.notifier import send_test_notification
r = await send_test_notification(target)
r = await send_test_notification(target, locale=locale)
return {"target": target.name, **r}
@@ -227,7 +227,7 @@ async def tracker_history(
"collection_id": e.collection_id,
"collection_name": e.collection_name,
"details": e.details,
"created_at": e.created_at.isoformat(),
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
}
for e in result.all()
]