Restructure data model: TrackingConfig + TemplateConfig entities
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
Major model restructuring for clean separation of concerns: New entities: - TrackingConfig: What to react to (event types, asset filters, periodic/scheduled/memory mode config) - reusable across targets - TemplateConfig: All ~15 template slots from blueprint (event messages, asset formatting, date/location, scheduled messages) with full defaults - separate entities per locale Changed entities: - AlbumTracker: Simplified to album selection + polling + target_ids (removed event_types, template_id, all filter fields) - NotificationTarget: Extended with tracking_config_id and template_config_id FKs (many-to-one, reusable configs) Removed entities: - MessageTemplate (replaced by TemplateConfig) - ScheduledJob (absorbed into TrackingConfig) Updated services: - watcher.py: Each target checked against its own tracking_config for event filtering before sending notification - notifier.py: Uses target's template_config to select the right template slot based on event type New API routes: - /api/tracking-configs/* (CRUD) - /api/template-configs/* (CRUD + per-slot preview) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,9 @@ from ..database.models import (
|
||||
AlbumTracker,
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
TemplateConfig,
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -90,13 +91,6 @@ async def get_sync_trackers(
|
||||
if not server:
|
||||
continue
|
||||
|
||||
# Fetch template body if assigned
|
||||
template_body = None
|
||||
if tracker.template_id:
|
||||
template = await session.get(MessageTemplate, tracker.template_id)
|
||||
if template:
|
||||
template_body = template.body
|
||||
|
||||
# Fetch target configs
|
||||
targets = []
|
||||
for target_id in tracker.target_ids:
|
||||
@@ -114,10 +108,10 @@ async def get_sync_trackers(
|
||||
server_url=server.url,
|
||||
server_api_key=server.api_key,
|
||||
album_ids=tracker.album_ids,
|
||||
event_types=tracker.event_types,
|
||||
event_types=[], # Event types now on tracking configs
|
||||
scan_interval=tracker.scan_interval,
|
||||
enabled=tracker.enabled,
|
||||
template_body=template_body,
|
||||
template_body=None,
|
||||
targets=targets,
|
||||
))
|
||||
|
||||
@@ -131,14 +125,14 @@ async def render_template(
|
||||
user: User = Depends(_get_user_by_api_key),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Render a template with provided context (for HA to use server-managed templates)."""
|
||||
template = await session.get(MessageTemplate, template_id)
|
||||
"""Render a template config slot with provided context."""
|
||||
template = await session.get(TemplateConfig, template_id)
|
||||
if not template or template.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template.body)
|
||||
tmpl = env.from_string(template.message_assets_added)
|
||||
rendered = tmpl.render(**body.context)
|
||||
return {"rendered": rendered}
|
||||
except jinja2.TemplateError as e:
|
||||
|
||||
@@ -16,11 +16,15 @@ class TargetCreate(BaseModel):
|
||||
type: str # "telegram" or "webhook"
|
||||
name: str
|
||||
config: dict # telegram: {bot_token, chat_id}, webhook: {url, headers?}
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
config: dict | None = None
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -33,7 +37,7 @@ async def list_targets(
|
||||
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()}
|
||||
{"id": t.id, "type": t.type, "name": t.name, "config": _safe_config(t), "tracking_config_id": t.tracking_config_id, "template_config_id": t.template_config_id, "created_at": t.created_at.isoformat()}
|
||||
for t in result.all()
|
||||
]
|
||||
|
||||
@@ -55,6 +59,8 @@ async def create_target(
|
||||
type=body.type,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
tracking_config_id=body.tracking_config_id,
|
||||
template_config_id=body.template_config_id,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
@@ -70,7 +76,7 @@ async def get_target(
|
||||
):
|
||||
"""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)}
|
||||
return {"id": target.id, "type": target.type, "name": target.name, "config": _safe_config(target), "tracking_config_id": target.tracking_config_id, "template_config_id": target.template_config_id}
|
||||
|
||||
|
||||
@router.put("/{target_id}")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Template configuration CRUD 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 jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
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"])
|
||||
|
||||
# Sample data for template preview
|
||||
_SAMPLE_CONTEXT = {
|
||||
"album_name": "Family Photos",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"album_created": "01.01.2024",
|
||||
"album_updated": "19.03.2026",
|
||||
"added_count": 3,
|
||||
"removed_count": 0,
|
||||
"change_type": "assets_added",
|
||||
"people": "Alice, Bob",
|
||||
"assets": "\n • 🖼️ IMG_001.jpg\n • 🖼️ IMG_002.jpg\n • 🎬 VID_003.mp4",
|
||||
"common_date": " from 19.03.2026",
|
||||
"common_location": " in Paris, France",
|
||||
"video_warning": "",
|
||||
"old_name": "Old Album",
|
||||
"new_name": "New Album",
|
||||
"album_count": 5,
|
||||
"albums": "\n • Family Photos: https://example.com/share/abc",
|
||||
"asset_count": 10,
|
||||
"more_count": 7,
|
||||
}
|
||||
|
||||
|
||||
class TemplateConfigCreate(BaseModel):
|
||||
name: str
|
||||
message_assets_added: str | None = None
|
||||
message_assets_removed: str | None = None
|
||||
message_album_renamed: str | None = None
|
||||
message_album_deleted: str | None = None
|
||||
message_asset_image: str | None = None
|
||||
message_asset_video: str | None = None
|
||||
message_assets_format: str | None = None
|
||||
message_assets_more: str | None = None
|
||||
message_people_format: str | None = None
|
||||
date_format: str | None = None
|
||||
common_date_template: str | None = None
|
||||
date_if_unique_template: str | None = None
|
||||
location_format: str | None = None
|
||||
common_location_template: str | None = None
|
||||
location_if_unique_template: str | None = None
|
||||
favorite_indicator: str | None = None
|
||||
periodic_summary_message: str | None = None
|
||||
periodic_album_template: str | None = None
|
||||
scheduled_assets_message: str | None = None
|
||||
memory_mode_message: str | None = None
|
||||
video_warning: str | None = None
|
||||
|
||||
|
||||
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_configs(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.exec(
|
||||
select(TemplateConfig).where(TemplateConfig.user_id == user.id)
|
||||
)
|
||||
return [_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
body: TemplateConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
config = TemplateConfig(user_id=user.id, **data)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
return _response(await _get(session, config_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_config(
|
||||
config_id: int,
|
||||
body: TemplateConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await _get(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
if value is not None:
|
||||
setattr(config, field, value)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await _get(session, config_id, user.id)
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{config_id}/preview")
|
||||
async def preview_config(
|
||||
config_id: int,
|
||||
slot: str = "message_assets_added",
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Render a specific template slot with sample data."""
|
||||
config = await _get(session, config_id, user.id)
|
||||
template_body = getattr(config, slot, None)
|
||||
if template_body is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}")
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_body)
|
||||
rendered = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
return {"slot": slot, "rendered": rendered}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
def _response(c: TemplateConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
|
||||
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")
|
||||
return config
|
||||
@@ -16,41 +16,21 @@ 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
|
||||
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):
|
||||
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
|
||||
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("")
|
||||
@@ -58,7 +38,6 @@ 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)
|
||||
)
|
||||
@@ -71,25 +50,11 @@ async def create_tracker(
|
||||
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,
|
||||
)
|
||||
tracker = AlbumTracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
@@ -102,9 +67,7 @@ async def get_tracker(
|
||||
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)
|
||||
return _tracker_response(await _get_user_tracker(session, tracker_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
@@ -114,7 +77,6 @@ async def update_tracker(
|
||||
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)
|
||||
@@ -130,7 +92,6 @@ async def delete_tracker(
|
||||
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()
|
||||
@@ -142,7 +103,6 @@ async def trigger_tracker(
|
||||
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)
|
||||
from ..services.watcher import check_tracker_with_session
|
||||
result = await check_tracker_with_session(tracker.id, session)
|
||||
@@ -156,7 +116,6 @@ async def tracker_history(
|
||||
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)
|
||||
@@ -183,21 +142,11 @@ def _tracker_response(t: AlbumTracker) -> dict:
|
||||
"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,
|
||||
"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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tracking configuration CRUD 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 TrackingConfig, User
|
||||
|
||||
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
||||
|
||||
|
||||
class TrackingConfigCreate(BaseModel):
|
||||
name: str
|
||||
track_assets_added: bool = True
|
||||
track_assets_removed: bool = False
|
||||
track_album_renamed: bool = True
|
||||
track_album_deleted: bool = True
|
||||
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"
|
||||
periodic_enabled: bool = False
|
||||
periodic_interval_days: int = 1
|
||||
periodic_start_date: str = "2025-01-01"
|
||||
periodic_times: str = "12:00"
|
||||
scheduled_enabled: bool = False
|
||||
scheduled_times: str = "09:00"
|
||||
scheduled_album_mode: str = "per_album"
|
||||
scheduled_limit: int = 10
|
||||
scheduled_favorite_only: bool = False
|
||||
scheduled_asset_type: str = "all"
|
||||
scheduled_min_rating: int = 0
|
||||
scheduled_order_by: str = "random"
|
||||
scheduled_order: str = "descending"
|
||||
memory_enabled: bool = False
|
||||
memory_times: str = "09:00"
|
||||
memory_album_mode: str = "combined"
|
||||
memory_limit: int = 10
|
||||
memory_favorite_only: bool = False
|
||||
memory_asset_type: str = "all"
|
||||
memory_min_rating: int = 0
|
||||
|
||||
|
||||
class TrackingConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
track_assets_added: bool | None = None
|
||||
track_assets_removed: bool | None = None
|
||||
track_album_renamed: bool | None = None
|
||||
track_album_deleted: bool | 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
|
||||
periodic_enabled: bool | None = None
|
||||
periodic_interval_days: int | None = None
|
||||
periodic_start_date: str | None = None
|
||||
periodic_times: str | None = None
|
||||
scheduled_enabled: bool | None = None
|
||||
scheduled_times: str | None = None
|
||||
scheduled_album_mode: str | None = None
|
||||
scheduled_limit: int | None = None
|
||||
scheduled_favorite_only: bool | None = None
|
||||
scheduled_asset_type: str | None = None
|
||||
scheduled_min_rating: int | None = None
|
||||
scheduled_order_by: str | None = None
|
||||
scheduled_order: str | None = None
|
||||
memory_enabled: bool | None = None
|
||||
memory_times: str | None = None
|
||||
memory_album_mode: str | None = None
|
||||
memory_limit: int | None = None
|
||||
memory_favorite_only: bool | None = None
|
||||
memory_asset_type: str | None = None
|
||||
memory_min_rating: int | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_configs(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.exec(
|
||||
select(TrackingConfig).where(TrackingConfig.user_id == user.id)
|
||||
)
|
||||
return [_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
body: TrackingConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = TrackingConfig(user_id=user.id, **body.model_dump())
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
return _response(await _get(session, config_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_config(
|
||||
config_id: int,
|
||||
body: TrackingConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await _get(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(config, field, value)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await _get(session, config_id, user.id)
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _response(c: TrackingConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TrackingConfig:
|
||||
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
|
||||
@@ -6,9 +6,9 @@ from .models import (
|
||||
AlbumTracker,
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
ScheduledJob,
|
||||
TemplateConfig,
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ __all__ = [
|
||||
"AlbumTracker",
|
||||
"EventLog",
|
||||
"ImmichServer",
|
||||
"MessageTemplate",
|
||||
"NotificationTarget",
|
||||
"TemplateConfig",
|
||||
"TrackingConfig",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -36,8 +36,121 @@ class ImmichServer(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class TrackingConfig(SQLModel, table=True):
|
||||
"""Tracking configuration: what events/assets to react to and scheduled modes."""
|
||||
|
||||
__tablename__ = "tracking_config"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str
|
||||
|
||||
# Event-driven tracking
|
||||
track_assets_added: bool = Field(default=True)
|
||||
track_assets_removed: bool = Field(default=False)
|
||||
track_album_renamed: bool = Field(default=True)
|
||||
track_album_deleted: bool = Field(default=True)
|
||||
track_images: bool = Field(default=True)
|
||||
track_videos: bool = Field(default=True)
|
||||
notify_favorites_only: bool = Field(default=False)
|
||||
|
||||
# Asset display in notifications
|
||||
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
|
||||
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_album_mode: str = Field(default="per_album") # per_album/combined/random
|
||||
scheduled_limit: int = Field(default=10)
|
||||
scheduled_favorite_only: bool = Field(default=False)
|
||||
scheduled_asset_type: str = Field(default="all") # all/photo/video
|
||||
scheduled_min_rating: int = Field(default=0)
|
||||
scheduled_order_by: str = Field(default="random")
|
||||
scheduled_order: str = Field(default="descending")
|
||||
|
||||
# Memory mode (On This Day)
|
||||
memory_enabled: bool = Field(default=False)
|
||||
memory_times: str = Field(default="09:00")
|
||||
memory_album_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):
|
||||
"""Message template configuration: all template slots from the blueprint."""
|
||||
|
||||
__tablename__ = "template_config"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str # e.g. "Default EN", "Default RU"
|
||||
|
||||
# Event messages
|
||||
message_assets_added: str = Field(
|
||||
default='📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}'
|
||||
)
|
||||
message_assets_removed: str = Field(
|
||||
default='🗑️ {removed_count} photo(s) removed from album "{album_name}".'
|
||||
)
|
||||
message_album_renamed: str = Field(
|
||||
default='✏️ Album "{old_name}" renamed to "{new_name}".'
|
||||
)
|
||||
message_album_deleted: str = Field(
|
||||
default='🗑️ Album "{album_name}" was deleted.'
|
||||
)
|
||||
|
||||
# Asset item formatting
|
||||
message_asset_image: str = Field(default="\n • 🖼️ {filename}")
|
||||
message_asset_video: str = Field(default="\n • 🎬 {filename}")
|
||||
message_assets_format: str = Field(default="\nAssets:{assets}")
|
||||
message_assets_more: str = Field(default="\n • ...and {more_count} more")
|
||||
message_people_format: str = Field(default=" People: {people}.")
|
||||
|
||||
# Date/location formatting
|
||||
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
|
||||
common_date_template: str = Field(default=" from {date}")
|
||||
date_if_unique_template: str = Field(default=" ({date})")
|
||||
location_format: str = Field(default="{city}, {country}")
|
||||
common_location_template: str = Field(default=" in {location}")
|
||||
location_if_unique_template: str = Field(default=" 📍 {location}")
|
||||
favorite_indicator: str = Field(default="❤️")
|
||||
|
||||
# Scheduled notification templates
|
||||
periodic_summary_message: str = Field(
|
||||
default="📋 Tracked Albums Summary ({album_count} albums):{albums}"
|
||||
)
|
||||
periodic_album_template: str = Field(
|
||||
default="\n • {album_name}: {album_url}"
|
||||
)
|
||||
scheduled_assets_message: str = Field(
|
||||
default='📸 Here are some photos from album "{album_name}":{assets}'
|
||||
)
|
||||
memory_mode_message: str = Field(default="📅 On this day:{assets}")
|
||||
|
||||
# Telegram-specific
|
||||
video_warning: str = Field(
|
||||
default="\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit."
|
||||
)
|
||||
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class NotificationTarget(SQLModel, table=True):
|
||||
"""Notification destination (Telegram chat, webhook URL)."""
|
||||
"""Notification destination with tracking and template config references."""
|
||||
|
||||
__tablename__ = "notification_target"
|
||||
|
||||
@@ -46,26 +159,13 @@ class NotificationTarget(SQLModel, table=True):
|
||||
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="")
|
||||
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)
|
||||
tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
|
||||
template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class AlbumTracker(SQLModel, table=True):
|
||||
"""Album change tracker configuration."""
|
||||
"""Album change tracker: which albums to poll and who to notify."""
|
||||
|
||||
__tablename__ = "album_tracker"
|
||||
|
||||
@@ -74,53 +174,11 @@ class AlbumTracker(SQLModel, table=True):
|
||||
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
|
||||
scan_interval: int = Field(default=60)
|
||||
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
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ from .services.scheduler import start_scheduler, stop_scheduler
|
||||
from .auth.routes import router as auth_router
|
||||
from .api.servers import router as servers_router
|
||||
from .api.trackers import router as trackers_router
|
||||
from .api.templates import router as templates_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.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(
|
||||
@@ -68,12 +68,12 @@ app.add_middleware(
|
||||
app.include_router(auth_router)
|
||||
app.include_router(servers_router)
|
||||
app.include_router(trackers_router)
|
||||
app.include_router(templates_router)
|
||||
app.include_router(tracking_configs_router)
|
||||
app.include_router(template_configs_router)
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from immich_watcher_core.telegram.client import TelegramClient
|
||||
|
||||
from ..database.models import MessageTemplate, NotificationTarget
|
||||
from ..database.models import NotificationTarget, TemplateConfig
|
||||
from ..webhook.client import WebhookClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -33,7 +33,7 @@ def render_template(template_body: str, context: dict[str, Any]) -> str:
|
||||
async def send_notification(
|
||||
target: NotificationTarget,
|
||||
event_data: dict[str, Any],
|
||||
template: MessageTemplate | None = None,
|
||||
template_config: TemplateConfig | None = None,
|
||||
use_ai_caption: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a notification to a target using event data.
|
||||
@@ -41,7 +41,7 @@ async def send_notification(
|
||||
Args:
|
||||
target: Notification destination (telegram or webhook)
|
||||
event_data: Album change event data (album_name, added_count, etc.)
|
||||
template: Optional message template (uses default if None)
|
||||
template_config: Optional template config with per-event templates
|
||||
use_ai_caption: If True, generate caption with Claude AI instead of template
|
||||
"""
|
||||
message = None
|
||||
@@ -54,7 +54,18 @@ async def send_notification(
|
||||
|
||||
# Fall back to template rendering
|
||||
if message is None:
|
||||
template_body = template.body if template else DEFAULT_TEMPLATE
|
||||
template_body = DEFAULT_TEMPLATE
|
||||
if template_config:
|
||||
# Select the right template slot based on event type
|
||||
change_type = event_data.get("change_type", "")
|
||||
slot_map = {
|
||||
"assets_added": "message_assets_added",
|
||||
"assets_removed": "message_assets_removed",
|
||||
"album_renamed": "message_album_renamed",
|
||||
"album_deleted": "message_album_deleted",
|
||||
}
|
||||
slot = slot_map.get(change_type, "message_assets_added")
|
||||
template_body = getattr(template_config, slot, DEFAULT_TEMPLATE) or DEFAULT_TEMPLATE
|
||||
try:
|
||||
message = render_template(template_body, event_data)
|
||||
except jinja2.TemplateError as e:
|
||||
|
||||
@@ -22,8 +22,9 @@ from ..database.models import (
|
||||
AlbumTracker,
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
MessageTemplate,
|
||||
NotificationTarget,
|
||||
TemplateConfig,
|
||||
TrackingConfig,
|
||||
)
|
||||
from .notifier import send_notification
|
||||
|
||||
@@ -58,9 +59,7 @@ async def check_tracker_with_session(
|
||||
# Eagerly read all needed data before entering aiohttp context
|
||||
# (SQLAlchemy async greenlet context doesn't survive across other async CMs)
|
||||
album_ids = list(tracker.album_ids)
|
||||
event_types = list(tracker.event_types)
|
||||
target_ids = list(tracker.target_ids)
|
||||
template_id = tracker.template_id
|
||||
tracker_db_id = tracker_id
|
||||
server_url = server.url
|
||||
server_api_key = server.api_key
|
||||
@@ -69,14 +68,13 @@ async def check_tracker_with_session(
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
client = ImmichClient(http_session, server_url, server_api_key)
|
||||
|
||||
# Fetch server config for external domain
|
||||
await client.get_server_config()
|
||||
users_cache = await client.get_users()
|
||||
|
||||
for album_id in album_ids:
|
||||
result = await _check_album(
|
||||
session, http_session, client, tracker_db_id,
|
||||
album_id, users_cache, event_types, target_ids, template_id,
|
||||
album_id, users_cache, target_ids,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
@@ -91,9 +89,7 @@ async def _check_album(
|
||||
tracker_id: int,
|
||||
album_id: str,
|
||||
users_cache: dict[str, str],
|
||||
event_types: list[str],
|
||||
target_ids: list[int],
|
||||
template_id: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check a single album for changes."""
|
||||
try:
|
||||
@@ -157,10 +153,6 @@ async def _check_album(
|
||||
if change is None:
|
||||
return {"album_id": album_id, "status": "no_changes"}
|
||||
|
||||
# Check if this event type is tracked
|
||||
if change.change_type not in event_types and "changed" not in event_types:
|
||||
return {"album_id": album_id, "status": "filtered", "change_type": change.change_type}
|
||||
|
||||
# Log the event
|
||||
shared_links = await client.get_shared_links(album_id)
|
||||
event_data = _build_event_data(change, album, client.external_url, shared_links)
|
||||
@@ -174,19 +166,41 @@ async def _check_album(
|
||||
)
|
||||
session.add(event_log)
|
||||
|
||||
# Send notifications to all configured targets
|
||||
# Send notifications to each target, filtered by its tracking config
|
||||
for target_id in target_ids:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
template = None
|
||||
if template_id:
|
||||
template = await session.get(MessageTemplate, template_id)
|
||||
# Check target's tracking config for event filtering
|
||||
tracking_config = None
|
||||
if target.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, target.tracking_config_id)
|
||||
|
||||
if tracking_config:
|
||||
# Filter by event type
|
||||
should_notify = False
|
||||
if change.change_type == "assets_added" and tracking_config.track_assets_added:
|
||||
should_notify = True
|
||||
elif change.change_type == "assets_removed" and tracking_config.track_assets_removed:
|
||||
should_notify = True
|
||||
elif change.change_type == "album_renamed" and tracking_config.track_album_renamed:
|
||||
should_notify = True
|
||||
elif change.change_type == "album_deleted" and tracking_config.track_album_deleted:
|
||||
should_notify = True
|
||||
elif change.change_type == "changed":
|
||||
should_notify = True # "changed" = mixed, always notify
|
||||
if not should_notify:
|
||||
continue
|
||||
|
||||
# Get target's template config
|
||||
template_config = None
|
||||
if target.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, target.template_config_id)
|
||||
|
||||
try:
|
||||
use_ai = target.config.get("ai_captions", False)
|
||||
await send_notification(target, event_data, template, use_ai_caption=use_ai)
|
||||
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to send notification to target %d", target_id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user