0562f78b35
Scheduler provider: - Virtual provider (no external service) that emits SCHEDULED_MESSAGE events on user-defined intervals or cron expressions - Custom variables stored in tracker filters, flattened into template context - fire_count persists across triggers via tracker state - APScheduler CronTrigger support for cron-mode schedules - Default templates (EN+RU), seeded on startup Multi-provider UX fixes: - Tracking config hides Immich-specific sections (periodic, scheduled, memory, asset display) for non-Immich providers - Command config driven by provider capabilities — hides commands/settings for providers without bot commands - Template config hides empty "Scheduled Messages" group - Test menu on tracker targets is provider-aware (Immich shows all 4 test types, others show only basic) - Removed redundant Test button from tracker card - System-owned tracking configs (user_id=0) seeded for Gitea + Scheduler - Fixed ownership checks to allow system configs in tracker-target links - Capabilities cache shared across template-configs and command-configs - Command tracker bot selector uses EntitySelect instead of raw select - Sample context includes Gitea + Scheduler variables for template preview
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""Service provider management API routes."""
|
|
|
|
import logging
|
|
|
|
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
|
|
from ..services import make_immich_provider, make_gitea_provider
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/providers", tags=["providers"])
|
|
|
|
|
|
class ProviderCreate(BaseModel):
|
|
type: str
|
|
name: str
|
|
icon: str = ""
|
|
config: dict[str, Any] = {}
|
|
|
|
|
|
class ProviderUpdate(BaseModel):
|
|
name: str | None = None
|
|
icon: str | None = None
|
|
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)
|
|
)
|
|
providers = result.all()
|
|
return [_provider_response(p) for p in providers]
|
|
|
|
|
|
@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"]
|
|
|
|
elif body.type == "gitea":
|
|
config = body.config
|
|
# api_token is optional (webhook_secret is required, but token only for repo listing)
|
|
if config.get("api_token"):
|
|
async with aiohttp.ClientSession() as http_session:
|
|
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
|
gitea = GiteaServiceProvider(
|
|
http_session, config.get("url", ""), config.get("api_token", ""), body.name,
|
|
)
|
|
test_result = await gitea.test_connection()
|
|
if not test_result.get("ok"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=test_result.get("message", "Cannot connect to Gitea"),
|
|
)
|
|
|
|
# Scheduler: no validation needed (virtual provider)
|
|
|
|
provider = ServiceProvider(
|
|
user_id=user.id,
|
|
type=body.type,
|
|
name=body.name,
|
|
icon=body.icon,
|
|
config=body.config,
|
|
)
|
|
session.add(provider)
|
|
await session.commit()
|
|
await session.refresh(provider)
|
|
return _provider_response(provider)
|
|
|
|
|
|
@router.get("/capabilities")
|
|
async def list_provider_capabilities():
|
|
"""List capabilities for all registered provider types."""
|
|
from notify_bridge_core.providers.capabilities import get_all_capabilities
|
|
result = {}
|
|
for pt, caps in get_all_capabilities().items():
|
|
result[pt] = {
|
|
"provider_type": caps.provider_type,
|
|
"display_name": caps.display_name,
|
|
"notification_slots": caps.notification_slots,
|
|
"command_slots": caps.command_slots,
|
|
"events": caps.events,
|
|
"commands": caps.commands,
|
|
"supported_filters": caps.supported_filters,
|
|
"webhook_based": caps.webhook_based,
|
|
}
|
|
return result
|
|
|
|
|
|
@router.get("/capabilities/{provider_type}")
|
|
async def get_provider_capabilities(provider_type: str):
|
|
"""Get capabilities for a provider type (events, slots, commands)."""
|
|
from notify_bridge_core.providers.capabilities import get_capabilities
|
|
caps = get_capabilities(provider_type)
|
|
if not caps:
|
|
raise HTTPException(status_code=404, detail=f"Unknown provider type: {provider_type}")
|
|
return {
|
|
"provider_type": caps.provider_type,
|
|
"display_name": caps.display_name,
|
|
"notification_slots": caps.notification_slots,
|
|
"command_slots": caps.command_slots,
|
|
"events": caps.events,
|
|
"commands": caps.commands,
|
|
"supported_filters": caps.supported_filters,
|
|
"webhook_based": caps.webhook_based,
|
|
}
|
|
|
|
|
|
@router.get("/{provider_id}")
|
|
async def get_provider(
|
|
provider_id: int,
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Get a specific service provider."""
|
|
provider = await _get_user_provider(session, provider_id, user.id)
|
|
return _provider_response(provider)
|
|
|
|
|
|
@router.put("/{provider_id}")
|
|
async def update_provider(
|
|
provider_id: int,
|
|
body: ProviderUpdate,
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""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:
|
|
async with aiohttp.ClientSession() as http_session:
|
|
immich = make_immich_provider(http_session, provider)
|
|
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}",
|
|
)
|
|
elif config_changed and provider.type == "gitea":
|
|
if provider.config.get("api_token"):
|
|
try:
|
|
async with aiohttp.ClientSession() as http_session:
|
|
gitea = make_gitea_provider(http_session, provider)
|
|
test_result = await gitea.test_connection()
|
|
if not test_result.get("ok"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=test_result.get("message", "Cannot connect to Gitea"),
|
|
)
|
|
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_response(provider)
|
|
|
|
|
|
@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),
|
|
):
|
|
"""Delete a service provider."""
|
|
from .delete_protection import check_service_provider, raise_if_used
|
|
provider = await _get_user_provider(session, provider_id, user.id)
|
|
raise_if_used(await check_service_provider(session, provider.id), provider.name)
|
|
await session.delete(provider)
|
|
await session.commit()
|
|
|
|
|
|
@router.post("/{provider_id}/test")
|
|
async def test_provider(
|
|
provider_id: int,
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Check if a service provider is reachable."""
|
|
provider = await _get_user_provider(session, provider_id, user.id)
|
|
|
|
if provider.type == "immich":
|
|
async with aiohttp.ClientSession() as http_session:
|
|
immich = make_immich_provider(http_session, provider)
|
|
return await immich.test_connection()
|
|
|
|
if provider.type == "gitea":
|
|
if not provider.config.get("api_token"):
|
|
return {"ok": True, "message": "Gitea webhook-only mode (no API token for testing)"}
|
|
async with aiohttp.ClientSession() as http_session:
|
|
gitea = make_gitea_provider(http_session, provider)
|
|
return await gitea.test_connection()
|
|
|
|
if provider.type == "scheduler":
|
|
return {"ok": True, "message": "Virtual provider — always available"}
|
|
|
|
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
|
|
|
|
|
|
@router.get("/{provider_id}/collections")
|
|
async def list_collections(
|
|
provider_id: int,
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Fetch collections from a service provider."""
|
|
provider = await _get_user_provider(session, provider_id, user.id)
|
|
|
|
if provider.type == "immich":
|
|
async with aiohttp.ClientSession() as http_session:
|
|
immich = make_immich_provider(http_session, provider)
|
|
return await immich.list_collections()
|
|
|
|
if provider.type == "gitea":
|
|
if not provider.config.get("api_token"):
|
|
return []
|
|
async with aiohttp.ClientSession() as http_session:
|
|
gitea = make_gitea_provider(http_session, provider)
|
|
return await gitea.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":
|
|
async with aiohttp.ClientSession() as http_session:
|
|
immich = make_immich_provider(http_session, provider)
|
|
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":
|
|
async with aiohttp.ClientSession() as http_session:
|
|
immich = make_immich_provider(http_session, provider)
|
|
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)
|
|
# Mask sensitive fields
|
|
for secret_field in ("api_key", "api_token", "webhook_secret"):
|
|
if secret_field in config:
|
|
key = config[secret_field]
|
|
config[secret_field] = 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
|