Add standalone FastAPI server backend (Phase 3)
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -0,0 +1 @@
|
||||
"""API routes package."""
|
||||
158
packages/server/src/immich_watcher_server/api/servers.py
Normal file
158
packages/server/src/immich_watcher_server/api/servers.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Immich server management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import aiohttp
|
||||
|
||||
from immich_watcher_core.immich_client import ImmichClient
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ImmichServer, User
|
||||
|
||||
router = APIRouter(prefix="/api/servers", tags=["servers"])
|
||||
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
name: str = "Immich"
|
||||
url: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
class ServerResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
url: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_servers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all Immich servers for the current user."""
|
||||
result = await session.exec(
|
||||
select(ImmichServer).where(ImmichServer.user_id == user.id)
|
||||
)
|
||||
servers = result.all()
|
||||
return [
|
||||
{"id": s.id, "name": s.name, "url": s.url, "created_at": s.created_at.isoformat()}
|
||||
for s in servers
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_server(
|
||||
body: ServerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Add a new Immich server (validates connection)."""
|
||||
# Validate connection
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
client = ImmichClient(http_session, body.url, body.api_key)
|
||||
if not await client.ping():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot connect to Immich server at {body.url}",
|
||||
)
|
||||
# Fetch external domain
|
||||
external_domain = await client.get_server_config()
|
||||
|
||||
server = ImmichServer(
|
||||
user_id=user.id,
|
||||
name=body.name,
|
||||
url=body.url,
|
||||
api_key=body.api_key,
|
||||
external_domain=external_domain,
|
||||
)
|
||||
session.add(server)
|
||||
await session.commit()
|
||||
await session.refresh(server)
|
||||
return {"id": server.id, "name": server.name, "url": server.url}
|
||||
|
||||
|
||||
@router.get("/{server_id}")
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a specific Immich server."""
|
||||
server = await _get_user_server(session, server_id, user.id)
|
||||
return {"id": server.id, "name": server.name, "url": server.url, "created_at": server.created_at.isoformat()}
|
||||
|
||||
|
||||
@router.put("/{server_id}")
|
||||
async def update_server(
|
||||
server_id: int,
|
||||
body: ServerUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update an Immich server."""
|
||||
server = await _get_user_server(session, server_id, user.id)
|
||||
if body.name is not None:
|
||||
server.name = body.name
|
||||
if body.url is not None:
|
||||
server.url = body.url
|
||||
if body.api_key is not None:
|
||||
server.api_key = body.api_key
|
||||
session.add(server)
|
||||
await session.commit()
|
||||
await session.refresh(server)
|
||||
return {"id": server.id, "name": server.name, "url": server.url}
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_server(
|
||||
server_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete an Immich server."""
|
||||
server = await _get_user_server(session, server_id, user.id)
|
||||
await session.delete(server)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/{server_id}/albums")
|
||||
async def list_albums(
|
||||
server_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Fetch albums from an Immich server."""
|
||||
server = await _get_user_server(session, server_id, user.id)
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
client = ImmichClient(http_session, server.url, server.api_key)
|
||||
albums = await client.get_albums()
|
||||
return [
|
||||
{
|
||||
"id": a.get("id"),
|
||||
"albumName": a.get("albumName"),
|
||||
"assetCount": a.get("assetCount", 0),
|
||||
"shared": a.get("shared", False),
|
||||
}
|
||||
for a in albums
|
||||
]
|
||||
|
||||
|
||||
async def _get_user_server(
|
||||
session: AsyncSession, server_id: int, user_id: int
|
||||
) -> ImmichServer:
|
||||
"""Get a server owned by the user, or raise 404."""
|
||||
server = await session.get(ImmichServer, server_id)
|
||||
if not server or server.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
return server
|
||||
55
packages/server/src/immich_watcher_server/api/status.py
Normal file
55
packages/server/src/immich_watcher_server/api/status.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Status/dashboard API route."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import func, 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 AlbumTracker, EventLog, ImmichServer, NotificationTarget, User
|
||||
|
||||
router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_status(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get dashboard status data."""
|
||||
servers_count = (await session.exec(
|
||||
select(func.count()).select_from(ImmichServer).where(ImmichServer.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
trackers_result = await session.exec(
|
||||
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
|
||||
)
|
||||
trackers = trackers_result.all()
|
||||
active_count = sum(1 for t in trackers if t.enabled)
|
||||
|
||||
targets_count = (await session.exec(
|
||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
recent_events = await session.exec(
|
||||
select(EventLog)
|
||||
.join(AlbumTracker, EventLog.tracker_id == AlbumTracker.id)
|
||||
.where(AlbumTracker.user_id == user.id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
return {
|
||||
"servers": servers_count,
|
||||
"trackers": {"total": len(trackers), "active": active_count},
|
||||
"targets": targets_count,
|
||||
"recent_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"album_name": e.album_name,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in recent_events.all()
|
||||
],
|
||||
}
|
||||
137
packages/server/src/immich_watcher_server/api/targets.py
Normal file
137
packages/server/src/immich_watcher_server/api/targets.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Notification target management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 NotificationTarget, User
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
|
||||
class TargetCreate(BaseModel):
|
||||
type: str # "telegram" or "webhook"
|
||||
name: str
|
||||
config: dict # telegram: {bot_token, chat_id}, webhook: {url, headers?}
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_targets(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all notification targets for the current user."""
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)
|
||||
return [
|
||||
{"id": t.id, "type": t.type, "name": t.name, "config": _safe_config(t), "created_at": t.created_at.isoformat()}
|
||||
for t in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_target(
|
||||
body: TargetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new notification target."""
|
||||
if body.type not in ("telegram", "webhook"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Type must be 'telegram' or 'webhook'",
|
||||
)
|
||||
target = NotificationTarget(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
return {"id": target.id, "type": target.type, "name": target.name}
|
||||
|
||||
|
||||
@router.get("/{target_id}")
|
||||
async def get_target(
|
||||
target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a specific notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
return {"id": target.id, "type": target.type, "name": target.name, "config": _safe_config(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),
|
||||
):
|
||||
"""Update a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
if body.name is not None:
|
||||
target.name = body.name
|
||||
if body.config is not None:
|
||||
target.config = body.config
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
return {"id": target.id, "type": target.type, "name": target.name}
|
||||
|
||||
|
||||
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_target(
|
||||
target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
await session.delete(target)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{target_id}/test")
|
||||
async def test_target(
|
||||
target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test notification to a target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await send_test_notification(target)
|
||||
return result
|
||||
|
||||
|
||||
def _safe_config(target: NotificationTarget) -> dict:
|
||||
"""Return config with sensitive fields masked."""
|
||||
config = dict(target.config)
|
||||
if "bot_token" in config:
|
||||
token = config["bot_token"]
|
||||
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
|
||||
if "api_key" in config:
|
||||
config["api_key"] = "***"
|
||||
return config
|
||||
|
||||
|
||||
async def _get_user_target(
|
||||
session: AsyncSession, target_id: int, user_id: int
|
||||
) -> NotificationTarget:
|
||||
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
|
||||
144
packages/server/src/immich_watcher_server/api/templates.py
Normal file
144
packages/server/src/immich_watcher_server/api/templates.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Message template management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import jinja2
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import MessageTemplate, User
|
||||
|
||||
router = APIRouter(prefix="/api/templates", tags=["templates"])
|
||||
|
||||
# Sample data for template preview
|
||||
_SAMPLE_CONTEXT = {
|
||||
"album_name": "Family Photos",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"added_count": 3,
|
||||
"removed_count": 0,
|
||||
"change_type": "assets_added",
|
||||
"people": ["Alice", "Bob"],
|
||||
"added_assets": [
|
||||
{"filename": "IMG_001.jpg", "type": "IMAGE", "owner": "Alice", "created_at": "2024-03-19T10:30:00Z"},
|
||||
{"filename": "IMG_002.jpg", "type": "IMAGE", "owner": "Bob", "created_at": "2024-03-19T11:00:00Z"},
|
||||
{"filename": "VID_003.mp4", "type": "VIDEO", "owner": "Alice", "created_at": "2024-03-19T11:30:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
body: str
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
body: str | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all templates for the current user."""
|
||||
result = await session.exec(
|
||||
select(MessageTemplate).where(MessageTemplate.user_id == user.id)
|
||||
)
|
||||
return [
|
||||
{"id": t.id, "name": t.name, "body": t.body, "is_default": t.is_default, "created_at": t.created_at.isoformat()}
|
||||
for t in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_template(
|
||||
body: TemplateCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new message template."""
|
||||
template = MessageTemplate(
|
||||
user_id=user.id,
|
||||
name=body.name,
|
||||
body=body.body,
|
||||
is_default=body.is_default,
|
||||
)
|
||||
session.add(template)
|
||||
await session.commit()
|
||||
await session.refresh(template)
|
||||
return {"id": template.id, "name": template.name}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a specific template."""
|
||||
template = await _get_user_template(session, template_id, user.id)
|
||||
return {"id": template.id, "name": template.name, "body": template.body, "is_default": template.is_default}
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
body: TemplateUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a template."""
|
||||
template = await _get_user_template(session, template_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(template, field, value)
|
||||
session.add(template)
|
||||
await session.commit()
|
||||
await session.refresh(template)
|
||||
return {"id": template.id, "name": template.name}
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_template(
|
||||
template_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a template."""
|
||||
template = await _get_user_template(session, template_id, user.id)
|
||||
await session.delete(template)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{template_id}/preview")
|
||||
async def preview_template(
|
||||
template_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Render a template with sample data."""
|
||||
template = await _get_user_template(session, template_id, user.id)
|
||||
try:
|
||||
env = jinja2.Environment(autoescape=False)
|
||||
tmpl = env.from_string(template.body)
|
||||
rendered = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
return {"rendered": rendered}
|
||||
except jinja2.TemplateError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Template error: {e}",
|
||||
)
|
||||
|
||||
|
||||
async def _get_user_template(
|
||||
session: AsyncSession, template_id: int, user_id: int
|
||||
) -> MessageTemplate:
|
||||
template = await session.get(MessageTemplate, template_id)
|
||||
if not template or template.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return template
|
||||
188
packages/server/src/immich_watcher_server/api/trackers.py
Normal file
188
packages/server/src/immich_watcher_server/api/trackers.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Album tracker management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 AlbumTracker, EventLog, ImmichServer, User
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
|
||||
|
||||
class TrackerCreate(BaseModel):
|
||||
server_id: int
|
||||
name: str
|
||||
album_ids: list[str]
|
||||
event_types: list[str] = ["assets_added"]
|
||||
target_ids: list[int] = []
|
||||
template_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
|
||||
album_ids: list[str] | None = None
|
||||
event_types: list[str] | None = None
|
||||
target_ids: list[int] | None = None
|
||||
template_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),
|
||||
):
|
||||
"""List all trackers for the current user."""
|
||||
result = await session.exec(
|
||||
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
|
||||
)
|
||||
return [_tracker_response(t) for t in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker(
|
||||
body: TrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new album tracker."""
|
||||
# Verify server ownership
|
||||
server = await session.get(ImmichServer, body.server_id)
|
||||
if not server or server.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
tracker = AlbumTracker(
|
||||
user_id=user.id,
|
||||
server_id=body.server_id,
|
||||
name=body.name,
|
||||
album_ids=body.album_ids,
|
||||
event_types=body.event_types,
|
||||
target_ids=body.target_ids,
|
||||
template_id=body.template_id,
|
||||
scan_interval=body.scan_interval,
|
||||
enabled=body.enabled,
|
||||
quiet_hours_start=body.quiet_hours_start,
|
||||
quiet_hours_end=body.quiet_hours_end,
|
||||
)
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return _tracker_response(tracker)
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
async def get_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a specific tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
return _tracker_response(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),
|
||||
):
|
||||
"""Update a tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
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_response(tracker)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/trigger")
|
||||
async def trigger_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Force an immediate check for a tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
# Import here to avoid circular imports
|
||||
from ..services.watcher import check_tracker
|
||||
result = await check_tracker(tracker.id)
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
limit: int = 20,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get recent events for a tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id == tracker_id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"album_id": e.album_id,
|
||||
"album_name": e.album_name,
|
||||
"details": e.details,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in result.all()
|
||||
]
|
||||
|
||||
|
||||
def _tracker_response(t: AlbumTracker) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"server_id": t.server_id,
|
||||
"album_ids": t.album_ids,
|
||||
"event_types": t.event_types,
|
||||
"target_ids": t.target_ids,
|
||||
"template_id": t.template_id,
|
||||
"scan_interval": t.scan_interval,
|
||||
"enabled": t.enabled,
|
||||
"quiet_hours_start": t.quiet_hours_start,
|
||||
"quiet_hours_end": t.quiet_hours_end,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> AlbumTracker:
|
||||
tracker = await session.get(AlbumTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
78
packages/server/src/immich_watcher_server/api/users.py
Normal file
78
packages/server/src/immich_watcher_server/api/users.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""User management API routes (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import bcrypt
|
||||
|
||||
from ..auth.dependencies import require_admin
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import User
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
result = await session.exec(select(User))
|
||||
return [
|
||||
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
|
||||
for u in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
# Check for duplicate username
|
||||
result = await session.exec(select(User).where(User.username == body.username))
|
||||
if result.first():
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
hashed_password=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
|
||||
role=body.role if body.role in ("admin", "user") else "user",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return {"id": user.id, "username": user.username, "role": user.role}
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a user (admin only, cannot delete self)."""
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
Reference in New Issue
Block a user