Add standalone FastAPI server backend (Phase 3)
Some checks failed
Validate / Hassfest (push) Has been cancelled

Build a complete standalone web server for Immich album change
notifications, independent of Home Assistant. Uses the shared
core library from Phase 1.

Server features:
- FastAPI with async SQLite (SQLModel + aiosqlite)
- Multi-user auth with JWT (admin/user roles, setup wizard)
- CRUD APIs: Immich servers, album trackers, message templates,
  notification targets (Telegram + webhook), user management
- APScheduler background polling per tracker
- Jinja2 template rendering with live preview
- Album browser proxied from Immich API
- Event logging and dashboard status endpoint
- Docker deployment (single container, SQLite in volume)

39 API routes, 14 integration tests passing.

Also adds Phase 6 (Claude AI Telegram bot enhancement) to the
primary plan as an optional future phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:56:22 +03:00
parent b107cfe67f
commit 58b2281dc6
28 changed files with 1982 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
"""Database package."""
from .engine import get_session, init_db
from .models import (
AlbumState,
AlbumTracker,
EventLog,
ImmichServer,
MessageTemplate,
NotificationTarget,
User,
)
__all__ = [
"get_session",
"init_db",
"AlbumState",
"AlbumTracker",
"EventLog",
"ImmichServer",
"MessageTemplate",
"NotificationTarget",
"User",
]

View File

@@ -0,0 +1,36 @@
"""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:
"""Get or create the async database engine."""
global _engine
if _engine is None:
_engine = create_async_engine(
settings.effective_database_url,
echo=settings.debug,
)
return _engine
async def init_db() -> None:
"""Create all database tables."""
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async database session (FastAPI dependency)."""
engine = get_engine()
async with AsyncSession(engine) as session:
yield session

View File

@@ -0,0 +1,112 @@
"""SQLModel database table definitions."""
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):
"""Application user."""
id: int | None = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
role: str = Field(default="user") # "admin" or "user"
created_at: datetime = Field(default_factory=_utcnow)
class ImmichServer(SQLModel, table=True):
"""Immich server connection."""
__tablename__ = "immich_server"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str = Field(default="Immich")
url: str
api_key: str
external_domain: str | None = None
created_at: datetime = Field(default_factory=_utcnow)
class NotificationTarget(SQLModel, table=True):
"""Notification destination (Telegram chat, webhook URL)."""
__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
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
class MessageTemplate(SQLModel, table=True):
"""Jinja2 message template."""
__tablename__ = "message_template"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str
body: str = Field(default="")
is_default: bool = Field(default=False)
created_at: datetime = Field(default_factory=_utcnow)
class AlbumTracker(SQLModel, table=True):
"""Album change tracker configuration."""
__tablename__ = "album_tracker"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
server_id: int = Field(foreign_key="immich_server.id")
name: str
album_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
event_types: list[str] = Field(
default_factory=lambda: ["assets_added"],
sa_column=Column(JSON),
)
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
template_id: int | None = Field(default=None, foreign_key="message_template.id")
scan_interval: int = Field(default=60) # seconds
enabled: bool = Field(default=True)
quiet_hours_start: str | None = None # "HH:MM"
quiet_hours_end: str | None = None # "HH:MM"
created_at: datetime = Field(default_factory=_utcnow)
class AlbumState(SQLModel, table=True):
"""Persisted album state for change detection across restarts."""
__tablename__ = "album_state"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int = Field(foreign_key="album_tracker.id")
album_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 album change events."""
__tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int = Field(foreign_key="album_tracker.id")
event_type: str
album_id: str
album_name: str
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)