Add enhanced models, scheduled jobs, per-locale templates (Phase 7b)
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
Backend changes for full blueprint feature parity: Database models: - MessageTemplate: add body_ru (locale variant), event_type field - AlbumTracker: add track_images, track_videos, notify_favorites_only, include_people, include_asset_details, max_assets_to_show, assets_order_by, assets_order fields - New ScheduledJob model: supports periodic_summary, scheduled_assets, and memory (On This Day) notification modes with full config (times, interval, album_mode, limit, filters, sorting) API: - New /api/scheduled/* CRUD endpoints for scheduled jobs - Tracker create/update accept all new filtering fields - Tracker response includes new fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
packages/server/src/immich_watcher_server/api/scheduled.py
Normal file
152
packages/server/src/immich_watcher_server/api/scheduled.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Scheduled notification job 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, ScheduledJob, User
|
||||
|
||||
router = APIRouter(prefix="/api/scheduled", tags=["scheduled"])
|
||||
|
||||
|
||||
class ScheduledJobCreate(BaseModel):
|
||||
tracker_id: int
|
||||
job_type: str # periodic_summary, scheduled_assets, memory
|
||||
enabled: bool = True
|
||||
times: str = "09:00"
|
||||
interval_days: int = 1
|
||||
start_date: str = "2025-01-01"
|
||||
album_mode: str = "per_album"
|
||||
limit: int = 10
|
||||
favorite_only: bool = False
|
||||
asset_type: str = "all"
|
||||
min_rating: int = 0
|
||||
order_by: str = "random"
|
||||
order: str = "descending"
|
||||
min_date: str | None = None
|
||||
max_date: str | None = None
|
||||
message_template: str = ""
|
||||
|
||||
|
||||
class ScheduledJobUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
times: str | None = None
|
||||
interval_days: int | None = None
|
||||
album_mode: str | None = None
|
||||
limit: int | None = None
|
||||
favorite_only: bool | None = None
|
||||
asset_type: str | None = None
|
||||
min_rating: int | None = None
|
||||
order_by: str | None = None
|
||||
order: str | None = None
|
||||
min_date: str | None = None
|
||||
max_date: str | None = None
|
||||
message_template: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_jobs(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all scheduled jobs for the current user's trackers."""
|
||||
trackers = await session.exec(
|
||||
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
|
||||
)
|
||||
tracker_ids = [t.id for t in trackers.all()]
|
||||
if not tracker_ids:
|
||||
return []
|
||||
|
||||
result = await session.exec(
|
||||
select(ScheduledJob).where(ScheduledJob.tracker_id.in_(tracker_ids))
|
||||
)
|
||||
return [_job_response(j) for j in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_job(
|
||||
body: ScheduledJobCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a scheduled notification job."""
|
||||
# Verify tracker ownership
|
||||
tracker = await session.get(AlbumTracker, body.tracker_id)
|
||||
if not tracker or tracker.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
|
||||
if body.job_type not in ("periodic_summary", "scheduled_assets", "memory"):
|
||||
raise HTTPException(status_code=400, detail="Invalid job_type")
|
||||
|
||||
job = ScheduledJob(**body.model_dump())
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
@router.put("/{job_id}")
|
||||
async def update_job(
|
||||
job_id: int,
|
||||
body: ScheduledJobUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a scheduled job."""
|
||||
job = await _get_user_job(session, job_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(job, field, value)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_job(
|
||||
job_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a scheduled job."""
|
||||
job = await _get_user_job(session, job_id, user.id)
|
||||
await session.delete(job)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _job_response(j: ScheduledJob) -> dict:
|
||||
return {
|
||||
"id": j.id,
|
||||
"tracker_id": j.tracker_id,
|
||||
"job_type": j.job_type,
|
||||
"enabled": j.enabled,
|
||||
"times": j.times,
|
||||
"interval_days": j.interval_days,
|
||||
"start_date": j.start_date,
|
||||
"album_mode": j.album_mode,
|
||||
"limit": j.limit,
|
||||
"favorite_only": j.favorite_only,
|
||||
"asset_type": j.asset_type,
|
||||
"min_rating": j.min_rating,
|
||||
"order_by": j.order_by,
|
||||
"order": j.order,
|
||||
"min_date": j.min_date,
|
||||
"max_date": j.max_date,
|
||||
"message_template": j.message_template,
|
||||
"created_at": j.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_job(
|
||||
session: AsyncSession, job_id: int, user_id: int
|
||||
) -> ScheduledJob:
|
||||
job = await session.get(ScheduledJob, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
tracker = await session.get(AlbumTracker, job.tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
@@ -23,6 +23,14 @@ class TrackerCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
track_images: bool = True
|
||||
track_videos: bool = True
|
||||
notify_favorites_only: bool = False
|
||||
include_people: bool = True
|
||||
include_asset_details: bool = False
|
||||
max_assets_to_show: int = 5
|
||||
assets_order_by: str = "none"
|
||||
assets_order: str = "descending"
|
||||
|
||||
|
||||
class TrackerUpdate(BaseModel):
|
||||
@@ -35,6 +43,14 @@ class TrackerUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
track_images: bool | None = None
|
||||
track_videos: bool | None = None
|
||||
notify_favorites_only: bool | None = None
|
||||
include_people: bool | None = None
|
||||
include_asset_details: bool | None = None
|
||||
max_assets_to_show: int | None = None
|
||||
assets_order_by: str | None = None
|
||||
assets_order: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -174,6 +190,14 @@ def _tracker_response(t: AlbumTracker) -> dict:
|
||||
"enabled": t.enabled,
|
||||
"quiet_hours_start": t.quiet_hours_start,
|
||||
"quiet_hours_end": t.quiet_hours_end,
|
||||
"track_images": t.track_images,
|
||||
"track_videos": t.track_videos,
|
||||
"notify_favorites_only": t.notify_favorites_only,
|
||||
"include_people": t.include_people,
|
||||
"include_asset_details": t.include_asset_details,
|
||||
"max_assets_to_show": t.max_assets_to_show,
|
||||
"assets_order_by": t.assets_order_by,
|
||||
"assets_order": t.assets_order,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -136,3 +136,5 @@ 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}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class Settings(BaseSettings):
|
||||
# Telegram webhook secret (used to validate incoming webhook requests)
|
||||
telegram_webhook_secret: str = ""
|
||||
|
||||
|
||||
model_config = {"env_prefix": "IMMICH_WATCHER_"}
|
||||
|
||||
@property
|
||||
|
||||
@@ -8,6 +8,7 @@ from .models import (
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
ScheduledJob,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ class MessageTemplate(SQLModel, table=True):
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str
|
||||
body: str = Field(default="")
|
||||
body_ru: str = Field(default="") # Russian locale variant (empty = use body)
|
||||
event_type: str = Field(default="") # "" = all events, or specific type
|
||||
is_default: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
@@ -82,6 +84,43 @@ class AlbumTracker(SQLModel, table=True):
|
||||
enabled: bool = Field(default=True)
|
||||
quiet_hours_start: str | None = None # "HH:MM"
|
||||
quiet_hours_end: str | None = None # "HH:MM"
|
||||
# Enhanced filtering (matching HAOS blueprint)
|
||||
track_images: bool = Field(default=True)
|
||||
track_videos: bool = Field(default=True)
|
||||
notify_favorites_only: bool = Field(default=False)
|
||||
include_people: 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") # none/date/rating/name/random
|
||||
assets_order: str = Field(default="descending")
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class ScheduledJob(SQLModel, table=True):
|
||||
"""Scheduled notification job (periodic summary, scheduled assets, memory mode)."""
|
||||
|
||||
__tablename__ = "scheduled_job"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="album_tracker.id")
|
||||
job_type: str # "periodic_summary", "scheduled_assets", "memory"
|
||||
enabled: bool = Field(default=True)
|
||||
# Timing
|
||||
times: str = Field(default="09:00") # "HH:MM, HH:MM"
|
||||
interval_days: int = Field(default=1) # For periodic: 1=daily, 7=weekly
|
||||
start_date: str = Field(default="2025-01-01") # For periodic interval anchor
|
||||
# Asset fetching config (scheduled_assets + memory modes)
|
||||
album_mode: str = Field(default="per_album") # per_album/combined/random
|
||||
limit: int = Field(default=10)
|
||||
favorite_only: bool = Field(default=False)
|
||||
asset_type: str = Field(default="all") # all/photo/video
|
||||
min_rating: int = Field(default=0) # 0=no filter, 1-5
|
||||
order_by: str = Field(default="random")
|
||||
order: str = Field(default="descending")
|
||||
min_date: str | None = None
|
||||
max_date: str | None = None
|
||||
# Template
|
||||
message_template: str = Field(default="") # Custom Jinja2 template for this job
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from .api.targets import router as targets_router
|
||||
from .api.users import router as users_router
|
||||
from .api.status import router as status_router
|
||||
from .api.sync import router as sync_router
|
||||
from .api.scheduled import router as scheduled_router
|
||||
from .ai.telegram_webhook import router as telegram_ai_router
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -72,6 +73,7 @@ app.include_router(targets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(status_router)
|
||||
app.include_router(sync_router)
|
||||
app.include_router(scheduled_router)
|
||||
app.include_router(telegram_ai_router)
|
||||
|
||||
# Serve frontend static files if available
|
||||
|
||||
Reference in New Issue
Block a user