feat: port full CRUD API routes and frontend pages from Immich Watcher

Backend API (38 routes):
- providers: full CRUD + test connection + list collections + API key masking
- trackers: full CRUD + trigger + history + test-periodic/memory
- tracking-configs: full CRUD with Pydantic models, provider_type filter
- template-configs: full CRUD + preview + preview-raw with two-pass validation
- targets: full CRUD + test notification + config masking
- telegram-bots: full CRUD + chat discovery + token endpoint
- users: full admin CRUD + password reset + self-delete protection
- status: dashboard endpoint with providers/trackers/targets/events counts

Frontend pages updated:
- Dashboard with animated stat cards and event timeline
- Providers with proper components, delete confirm, snackbar
- Trackers/targets/tracking-configs/template-configs/telegram-bots/users
  all use PageHeader, Card, Loading, MdiIcon with correct i18n keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:49:40 +03:00
parent c9cab93d12
commit 9eec21a5b2
17 changed files with 1596 additions and 244 deletions
@@ -1,6 +1,6 @@
"""NotificationTarget CRUD API routes."""
"""Notification target management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/targets", tags=["targets"])
class TargetCreate(BaseModel):
type: str
type: str # "telegram" or "webhook"
name: str
icon: str = ""
config: dict[str, Any] = {}
@@ -33,23 +33,48 @@ async def list_targets(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all notification targets for the current user."""
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return result.all()
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()
]
@router.post("", status_code=201)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_target(
body: TargetCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = NotificationTarget(user_id=user.id, **body.model_dump())
"""Create a new notification target."""
if body.type not in ("telegram", "webhook"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Type must be 'telegram' or 'webhook'",
)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
template_config_id=body.template_config_id,
)
session.add(target)
await session.commit()
await session.refresh(target)
return target
return {"id": target.id, "type": target.type, "name": target.name}
@router.get("/{target_id}")
@@ -58,10 +83,16 @@ async def get_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
return 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,
}
@router.put("/{target_id}")
@@ -71,27 +102,62 @@ async def update_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
"""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
session.add(target)
await session.commit()
await session.refresh(target)
return target
return {"id": target.id, "type": target.type, "name": target.name}
@router.delete("/{target_id}", status_code=204)
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_target(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user.id:
raise HTTPException(status_code=404, detail="Target not found")
"""Delete a notification target."""
target = await _get_user_target(session, target_id, user.id)
await session.delete(target)
await session.commit()
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
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)
return result
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
if "bot_token" in config:
token = config["bot_token"]
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
if "api_key" in config:
config["api_key"] = "***"
return config
async def _get_user_target(
session: AsyncSession, target_id: int, user_id: int
) -> NotificationTarget:
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user_id:
raise HTTPException(status_code=404, detail="Target not found")
return target