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:
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"bcrypt>=4.2",
|
"bcrypt>=4.2",
|
||||||
"apscheduler>=3.10,<4",
|
"apscheduler>=3.10,<4",
|
||||||
"aiohttp>=3.9",
|
"aiohttp>=3.9",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
"anthropic>=0.42",
|
"anthropic>=0.42",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""FastAPI dependencies for authentication."""
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from ..database.engine import get_session
|
||||||
|
from ..database.models import User
|
||||||
|
from .jwt import decode_token
|
||||||
|
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> User:
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (jwt.PyJWTError, KeyError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
|
||||||
|
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""JWT token creation and validation."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: int, role: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
payload = {"sub": str(user_id), "role": role, "type": "access", "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: int) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
||||||
|
payload = {"sub": str(user_id), "type": "refresh", "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Authentication API routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import func, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from ..database.engine import get_session
|
||||||
|
from ..database.models import User
|
||||||
|
from .dependencies import get_current_user
|
||||||
|
from .jwt import create_access_token, create_refresh_token, decode_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(password: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup", response_model=TokenResponse)
|
||||||
|
async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.exec(select(func.count()).select_from(User))
|
||||||
|
count = result.one()
|
||||||
|
if count > 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.")
|
||||||
|
|
||||||
|
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id, user.role),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.exec(select(User).where(User.username == body.username))
|
||||||
|
user = result.first()
|
||||||
|
if not user or not _verify_password(body.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id, user.role),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
|
||||||
|
import jwt as pyjwt
|
||||||
|
try:
|
||||||
|
payload = decode_token(body.refresh_token)
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id, user.role),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def me(user: User = Depends(get_current_user)):
|
||||||
|
return UserResponse(id=user.id, username=user.username, role=user.role)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/password")
|
||||||
|
async def change_password(
|
||||||
|
body: PasswordChangeRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
if not _verify_password(body.current_password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
if len(body.new_password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
||||||
|
user.hashed_password = _hash_password(body.new_password)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/needs-setup")
|
||||||
|
async def needs_setup(session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.exec(select(func.count()).select_from(User))
|
||||||
|
count = result.one()
|
||||||
|
return {"needs_setup": count == 0}
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
"""Server configuration — settings, data directory, secrets."""
|
"""Server configuration from environment variables."""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("NOTIFY_BRIDGE_DATA_DIR", "./data"))
|
class Settings(BaseSettings):
|
||||||
SECRET_KEY = os.environ.get("NOTIFY_BRIDGE_SECRET_KEY", "")
|
"""Application settings loaded from environment variables."""
|
||||||
DATABASE_URL = os.environ.get(
|
|
||||||
"NOTIFY_BRIDGE_DATABASE_URL",
|
data_dir: Path = Path("/data")
|
||||||
f"sqlite+aiosqlite:///{DATA_DIR / 'notify_bridge.db'}",
|
database_url: str = ""
|
||||||
)
|
|
||||||
|
secret_key: str = "change-me-in-production"
|
||||||
|
|
||||||
|
def model_post_init(self, __context: Any) -> None:
|
||||||
|
if self.secret_key == "change-me-in-production" and not self.debug:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).critical(
|
||||||
|
"SECURITY: Using default secret_key! "
|
||||||
|
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token_expire_minutes: int = 60
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8420
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
ai_model: str = "claude-sonnet-4-20250514"
|
||||||
|
ai_max_tokens: int = 1024
|
||||||
|
|
||||||
|
telegram_webhook_secret: str = ""
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "NOTIFY_BRIDGE_"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_database_url(self) -> str:
|
||||||
|
if self.database_url:
|
||||||
|
return self.database_url
|
||||||
|
db_path = self.data_dir / "notify_bridge.db"
|
||||||
|
return f"sqlite+aiosqlite:///{db_path}"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Database engine and session management."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_async_engine(
|
||||||
|
settings.effective_database_url,
|
||||||
|
echo=settings.debug,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
engine = get_engine()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""SQLModel database table definitions for Notify Bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import JSON, Column, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
username: str = Field(index=True, unique=True)
|
||||||
|
hashed_password: str
|
||||||
|
role: str = Field(default="user")
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceProvider(SQLModel, table=True):
|
||||||
|
"""A service provider instance (e.g., an Immich server)."""
|
||||||
|
|
||||||
|
__tablename__ = "service_provider"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
type: str # ServiceProviderType value ("immich")
|
||||||
|
name: str
|
||||||
|
icon: str = Field(default="")
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBot(SQLModel, table=True):
|
||||||
|
__tablename__ = "telegram_bot"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
name: str
|
||||||
|
token: str
|
||||||
|
icon: str = Field(default="")
|
||||||
|
bot_username: str = Field(default="")
|
||||||
|
bot_id: int = Field(default=0)
|
||||||
|
commands_config: dict[str, Any] = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"enabled": ["status", "albums", "events", "summary", "latest",
|
||||||
|
"memory", "random", "search", "find", "person",
|
||||||
|
"place", "favorites", "people", "help"],
|
||||||
|
"default_count": 5,
|
||||||
|
"response_mode": "media",
|
||||||
|
"rate_limits": {"search": 30, "find": 30, "default": 10},
|
||||||
|
"locale": "en",
|
||||||
|
},
|
||||||
|
sa_column=Column(JSON),
|
||||||
|
)
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramChat(SQLModel, table=True):
|
||||||
|
__tablename__ = "telegram_chat"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
bot_id: int = Field(foreign_key="telegram_bot.id")
|
||||||
|
chat_id: str
|
||||||
|
title: str = Field(default="")
|
||||||
|
chat_type: str = Field(default="private")
|
||||||
|
username: str = Field(default="")
|
||||||
|
discovered_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingConfig(SQLModel, table=True):
|
||||||
|
"""What events to track + scheduling rules. Tied to a provider type."""
|
||||||
|
|
||||||
|
__tablename__ = "tracking_config"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
provider_type: str # Must match provider's type
|
||||||
|
name: str
|
||||||
|
icon: str = Field(default="")
|
||||||
|
|
||||||
|
# Event-driven tracking
|
||||||
|
track_assets_added: bool = Field(default=True)
|
||||||
|
track_assets_removed: bool = Field(default=False)
|
||||||
|
track_collection_renamed: bool = Field(default=True)
|
||||||
|
track_collection_deleted: bool = Field(default=True)
|
||||||
|
track_sharing_changed: bool = Field(default=False)
|
||||||
|
track_images: bool = Field(default=True)
|
||||||
|
track_videos: bool = Field(default=True)
|
||||||
|
notify_favorites_only: bool = Field(default=False)
|
||||||
|
|
||||||
|
# Asset display
|
||||||
|
include_tags: bool = Field(default=True)
|
||||||
|
include_asset_details: bool = Field(default=False)
|
||||||
|
max_assets_to_show: int = Field(default=5)
|
||||||
|
assets_order_by: str = Field(default="none")
|
||||||
|
assets_order: str = Field(default="descending")
|
||||||
|
|
||||||
|
# Periodic summary
|
||||||
|
periodic_enabled: bool = Field(default=False)
|
||||||
|
periodic_interval_days: int = Field(default=1)
|
||||||
|
periodic_start_date: str = Field(default="2025-01-01")
|
||||||
|
periodic_times: str = Field(default="12:00")
|
||||||
|
|
||||||
|
# Scheduled assets
|
||||||
|
scheduled_enabled: bool = Field(default=False)
|
||||||
|
scheduled_times: str = Field(default="09:00")
|
||||||
|
scheduled_collection_mode: str = Field(default="per_collection")
|
||||||
|
scheduled_limit: int = Field(default=10)
|
||||||
|
scheduled_favorite_only: bool = Field(default=False)
|
||||||
|
scheduled_asset_type: str = Field(default="all")
|
||||||
|
scheduled_min_rating: int = Field(default=0)
|
||||||
|
scheduled_order_by: str = Field(default="random")
|
||||||
|
scheduled_order: str = Field(default="descending")
|
||||||
|
|
||||||
|
# Memory mode
|
||||||
|
memory_enabled: bool = Field(default=False)
|
||||||
|
memory_times: str = Field(default="09:00")
|
||||||
|
memory_collection_mode: str = Field(default="combined")
|
||||||
|
memory_limit: int = Field(default=10)
|
||||||
|
memory_favorite_only: bool = Field(default=False)
|
||||||
|
memory_asset_type: str = Field(default="all")
|
||||||
|
memory_min_rating: int = Field(default=0)
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateConfig(SQLModel, table=True):
|
||||||
|
"""Jinja2 message templates. Tied to a provider type."""
|
||||||
|
|
||||||
|
__tablename__ = "template_config"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
provider_type: str # Must match provider's type
|
||||||
|
name: str
|
||||||
|
description: str = Field(default="")
|
||||||
|
icon: str = Field(default="")
|
||||||
|
|
||||||
|
# Event-driven notification templates
|
||||||
|
message_assets_added: str = Field(default="")
|
||||||
|
message_assets_removed: str = Field(default="")
|
||||||
|
message_collection_renamed: str = Field(default="")
|
||||||
|
message_collection_deleted: str = Field(default="")
|
||||||
|
message_sharing_changed: str = Field(default="")
|
||||||
|
|
||||||
|
# Scheduled notification templates
|
||||||
|
periodic_summary_message: str = Field(default="")
|
||||||
|
scheduled_assets_message: str = Field(default="")
|
||||||
|
memory_mode_message: str = Field(default="")
|
||||||
|
|
||||||
|
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTarget(SQLModel, table=True):
|
||||||
|
"""Where to send notifications. Owns the template config."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_target"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
type: str # "telegram" or "webhook"
|
||||||
|
name: str
|
||||||
|
icon: str = Field(default="")
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Tracker(SQLModel, table=True):
|
||||||
|
"""Watches a provider's collections for changes. Owns the tracking config."""
|
||||||
|
|
||||||
|
__tablename__ = "tracker"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
provider_id: int = Field(foreign_key="service_provider.id")
|
||||||
|
name: str
|
||||||
|
icon: str = Field(default="")
|
||||||
|
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||||
|
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
|
||||||
|
tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
|
||||||
|
scan_interval: int = Field(default=60)
|
||||||
|
enabled: bool = Field(default=True)
|
||||||
|
quiet_hours_start: str | None = None
|
||||||
|
quiet_hours_end: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerState(SQLModel, table=True):
|
||||||
|
"""Persisted state for change detection."""
|
||||||
|
|
||||||
|
__tablename__ = "tracker_state"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
tracker_id: int = Field(foreign_key="tracker.id")
|
||||||
|
collection_id: str
|
||||||
|
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||||
|
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||||
|
last_updated: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog(SQLModel, table=True):
|
||||||
|
"""Log of detected events."""
|
||||||
|
|
||||||
|
__tablename__ = "event_log"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
|
||||||
|
event_type: str
|
||||||
|
collection_id: str
|
||||||
|
collection_name: str
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
@@ -1,8 +1,38 @@
|
|||||||
"""Notify Bridge Server — FastAPI application entry point."""
|
"""Notify Bridge Server — FastAPI application entry point."""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
app = FastAPI(title="Notify Bridge", version="0.1.0")
|
from .database.engine import init_db
|
||||||
|
from .database.models import * # noqa: F401,F403 — ensure all models registered
|
||||||
|
|
||||||
|
from .auth.routes import router as auth_router
|
||||||
|
from .api.providers import router as providers_router
|
||||||
|
from .api.trackers import router as trackers_router
|
||||||
|
from .api.tracking_configs import router as tracking_configs_router
|
||||||
|
from .api.template_configs import router as template_configs_router
|
||||||
|
from .api.targets import router as targets_router
|
||||||
|
from .api.template_vars import router as template_vars_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
await _seed_default_templates()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Register routes — static paths before parameterized
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(template_vars_router)
|
||||||
|
app.include_router(providers_router)
|
||||||
|
app.include_router(trackers_router)
|
||||||
|
app.include_router(tracking_configs_router)
|
||||||
|
app.include_router(template_configs_router)
|
||||||
|
app.include_router(targets_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -10,6 +40,38 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_default_templates():
|
||||||
|
"""Seed default templates on first startup if no templates exist."""
|
||||||
|
from sqlmodel import func, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from .database.engine import get_engine
|
||||||
|
from .database.models import TemplateConfig
|
||||||
|
from notify_bridge_core.templates.defaults import load_default_templates
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(select(func.count()).select_from(TemplateConfig))
|
||||||
|
count = result.one()
|
||||||
|
if count > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
for locale in ("en", "ru"):
|
||||||
|
slots = load_default_templates(locale)
|
||||||
|
if not slots:
|
||||||
|
continue
|
||||||
|
name = f"Default ({locale.upper()})"
|
||||||
|
config = TemplateConfig(
|
||||||
|
user_id=0,
|
||||||
|
provider_type="immich",
|
||||||
|
name=name,
|
||||||
|
description=f"Default Immich templates ({locale.upper()})",
|
||||||
|
**slots,
|
||||||
|
)
|
||||||
|
session.add(config)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8420)
|
uvicorn.run(app, host="0.0.0.0", port=8420)
|
||||||
|
|||||||
Reference in New Issue
Block a user