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
@@ -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)