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:
@@ -1,11 +1,13 @@
|
||||
"""Service Provider CRUD API routes."""
|
||||
"""Service provider 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
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
@@ -26,23 +28,57 @@ class ProviderUpdate(BaseModel):
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
name: str
|
||||
icon: str
|
||||
config: dict[str, Any]
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_providers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all service providers for the current user."""
|
||||
result = await session.exec(
|
||||
select(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||
)
|
||||
return result.all()
|
||||
providers = result.all()
|
||||
return [_provider_response(p) for p in providers]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
body: ProviderCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Add a new service provider (validates connection for known types)."""
|
||||
# Validate connection for known provider types
|
||||
if body.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = body.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
body.name,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", f"Cannot connect to {body.type} provider"),
|
||||
)
|
||||
# Store external_domain from server config if available
|
||||
if test_result.get("external_domain"):
|
||||
config["external_domain"] = test_result["external_domain"]
|
||||
|
||||
provider = ServiceProvider(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
@@ -53,7 +89,7 @@ async def create_provider(
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
return provider
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.get("/{provider_id}")
|
||||
@@ -62,10 +98,9 @@ async def get_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return provider
|
||||
"""Get a specific service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.put("/{provider_id}")
|
||||
@@ -75,32 +110,58 @@ async def update_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
"""Update a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
if body.name is not None:
|
||||
provider.name = body.name
|
||||
if body.icon is not None:
|
||||
provider.icon = body.icon
|
||||
|
||||
config_changed = body.config is not None and body.config != provider.config
|
||||
if body.config is not None:
|
||||
provider.config = body.config
|
||||
|
||||
# Re-validate connection when config changes for known provider types
|
||||
if config_changed and provider.type == "immich":
|
||||
try:
|
||||
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,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", f"Cannot connect to {provider.type} provider"),
|
||||
)
|
||||
if test_result.get("external_domain"):
|
||||
provider.config = {**provider.config, "external_domain": test_result["external_domain"]}
|
||||
except aiohttp.ClientError as err:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection error: {err}",
|
||||
)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
return provider
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.delete("/{provider_id}", status_code=204)
|
||||
@router.delete("/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_provider(
|
||||
provider_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Delete a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
await session.delete(provider)
|
||||
await session.commit()
|
||||
|
||||
@@ -111,12 +172,10 @@ async def test_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Check if a service provider is reachable."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
import aiohttp
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
@@ -138,12 +197,10 @@ async def list_collections(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Fetch collections from a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
import aiohttp
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
@@ -157,3 +214,30 @@ async def list_collections(
|
||||
return await immich.list_collections()
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _provider_response(p: ServiceProvider) -> dict:
|
||||
"""Build a safe response dict for a provider."""
|
||||
config = dict(p.config)
|
||||
# Mask sensitive fields
|
||||
if "api_key" in config:
|
||||
key = config["api_key"]
|
||||
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
||||
return {
|
||||
"id": p.id,
|
||||
"type": p.type,
|
||||
"name": p.name,
|
||||
"icon": p.icon,
|
||||
"config": config,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_provider(
|
||||
session: AsyncSession, provider_id: int, user_id: int
|
||||
) -> ServiceProvider:
|
||||
"""Get a provider owned by the user, or raise 404."""
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return provider
|
||||
|
||||
Reference in New Issue
Block a user