feat(notify-bridge): phase 6 - database models and server API

New database schema with ServiceProvider abstraction:
- ServiceProvider (replaces ImmichServer): type + JSON config
- Tracker (replaces AlbumTracker): owns tracking_config_id
- TrackingConfig: provider_type scoped, owned by Tracker
- TemplateConfig: provider_type scoped, owned by Target
- NotificationTarget: owns template_config_id (not tracking_config_id)
- TrackerState, EventLog, User, TelegramBot, TelegramChat

Full FastAPI server:
- /api/providers: CRUD + test connection + list collections
- /api/trackers: CRUD
- /api/tracking-configs: CRUD with provider_type filter
- /api/template-configs: CRUD with provider_type filter, system defaults
- /api/targets: CRUD
- /api/template-vars: variable docs filtered by provider type
- /api/auth: setup, login, refresh, me, password change
- /api/health: health check
- Default template seeding on first startup (EN/RU for Immich)
- pydantic-settings with NOTIFY_BRIDGE_ env prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:39:23 +03:00
parent 16a41efec1
commit 7f99c895a4
14 changed files with 1116 additions and 9 deletions
@@ -0,0 +1,159 @@
"""Service Provider CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
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
@router.get("")
async def list_providers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(ServiceProvider).where(ServiceProvider.user_id == user.id)
)
return result.all()
@router.post("", status_code=201)
async def create_provider(
body: ProviderCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
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
@router.get("/{provider_id}")
async def get_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")
return 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),
):
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")
if body.name is not None:
provider.name = body.name
if body.icon is not None:
provider.icon = body.icon
if body.config is not None:
provider.config = body.config
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
@router.delete("/{provider_id}", status_code=204)
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")
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),
):
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")
if provider.type == "immich":
import aiohttp
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,
)
return await immich.test_connection()
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),
):
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")
if provider.type == "immich":
import aiohttp
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,
)
return await immich.list_collections()
return []
@@ -0,0 +1,97 @@
"""NotificationTarget CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
class TargetCreate(BaseModel):
type: str
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("")
async def list_targets(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return result.all()
@router.post("", status_code=201)
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())
session.add(target)
await session.commit()
await session.refresh(target)
return target
@router.get("/{target_id}")
async def get_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")
return target
@router.put("/{target_id}")
async def update_target(
target_id: int,
body: TargetUpdate,
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)
session.add(target)
await session.commit()
await session.refresh(target)
return target
@router.delete("/{target_id}", status_code=204)
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")
await session.delete(target)
await session.commit()
@@ -0,0 +1,85 @@
"""TemplateConfig CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, User
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
@router.get("")
async def list_template_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
query = select(TemplateConfig).where(
(TemplateConfig.user_id == user.id) | (TemplateConfig.user_id == 0)
)
if provider_type:
query = query.where(TemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return result.all()
@router.post("", status_code=201)
async def create_template_config(
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TemplateConfig(user_id=user.id, **body)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.get("/{config_id}")
async def get_template_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user.id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
@router.put("/{config_id}")
async def update_template_config(
config_id: int,
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Template config not found")
for field, value in body.items():
if field not in ("id", "user_id", "created_at"):
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.delete("/{config_id}", status_code=204)
async def delete_template_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TemplateConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Template config not found")
await session.delete(config)
await session.commit()
@@ -0,0 +1,33 @@
"""Template variable documentation endpoint."""
from fastapi import APIRouter
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.providers.immich import ImmichServiceProvider # noqa: F401 — triggers registration
from notify_bridge_core.templates.variables import registry
router = APIRouter(prefix="/api/template-vars", tags=["template-vars"])
@router.get("")
async def get_template_variables(provider_type: str | None = None):
"""Get available template variables, optionally filtered by provider type."""
if provider_type:
try:
pt = ServiceProviderType(provider_type)
except ValueError:
return {"error": f"Unknown provider type: {provider_type}"}
variables = registry.get_variables(pt)
else:
variables = registry.get_base_variables()
return [
{
"name": v.name,
"type": v.type,
"description": v.description,
"example": v.example,
"provider_type": v.provider_type.value if v.provider_type else None,
}
for v in variables
]
@@ -0,0 +1,104 @@
"""Tracker CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import Tracker, User
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
class TrackerCreate(BaseModel):
provider_id: int
name: str
icon: str = ""
collection_ids: list[str] = []
target_ids: list[int] = []
tracking_config_id: int | None = None
scan_interval: int = 60
enabled: bool = True
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackerUpdate(BaseModel):
name: str | None = None
icon: str | None = None
collection_ids: list[str] | None = None
target_ids: list[int] | None = None
tracking_config_id: int | None = None
scan_interval: int | None = None
enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
async def list_trackers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(select(Tracker).where(Tracker.user_id == user.id))
return result.all()
@router.post("", status_code=201)
async def create_tracker(
body: TrackerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = Tracker(user_id=user.id, **body.model_dump())
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return tracker
@router.get("/{tracker_id}")
async def get_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
return tracker
@router.put("/{tracker_id}")
async def update_tracker(
tracker_id: int,
body: TrackerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(tracker, field, value)
session.add(tracker)
await session.commit()
await session.refresh(tracker)
return tracker
@router.delete("/{tracker_id}", status_code=204)
async def delete_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
tracker = await session.get(Tracker, tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
await session.delete(tracker)
await session.commit()
@@ -0,0 +1,83 @@
"""TrackingConfig CRUD API routes."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
@router.get("")
async def list_tracking_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
query = select(TrackingConfig).where(TrackingConfig.user_id == user.id)
if provider_type:
query = query.where(TrackingConfig.provider_type == provider_type)
result = await session.exec(query)
return result.all()
@router.post("", status_code=201)
async def create_tracking_config(
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TrackingConfig(user_id=user.id, **body)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.get("/{config_id}")
async def get_tracking_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
return config
@router.put("/{config_id}")
async def update_tracking_config(
config_id: int,
body: dict,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
for field, value in body.items():
if field not in ("id", "user_id", "created_at"):
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
return config
@router.delete("/{config_id}", status_code=204)
async def delete_tracking_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await session.get(TrackingConfig, config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
await session.delete(config)
await session.commit()