Phase 8: Server health, album filter, Jinja2 engine, password change
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
5 features implemented:
1. Server health indicator: green/red/yellow dot on each server card.
Pings Immich in background on page load. New GET /api/servers/{id}/ping.
2. Album selector filter: search input above album list in tracker form.
Filters by name as you type (case-insensitive). Shows total count.
3. Album last update time: each album in the selector shows its
updatedAt date. Backend now returns updatedAt from Immich API.
4. Full Jinja2 template engine in notifier:
- build_full_context() assembles all ~40 variables from blueprint
- Common date/location detection across assets
- Per-asset date/location when they differ
- Favorite indicator, people formatting, asset list with truncation
- Video warning for Telegram
- All template slots from TemplateConfig used contextually
5. Password change: PUT /api/auth/password endpoint (validates current
password, min 6 chars). UI in sidebar footer with inline form.
Also: Phase 9 plan (Telegram bot management) added.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,20 @@ async def delete_server(
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/{server_id}/ping")
|
||||
async def ping_server(
|
||||
server_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Check if an Immich server is reachable."""
|
||||
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)
|
||||
ok = await client.ping()
|
||||
return {"online": ok}
|
||||
|
||||
|
||||
@router.get("/{server_id}/albums")
|
||||
async def list_albums(
|
||||
server_id: int,
|
||||
@@ -143,6 +157,7 @@ async def list_albums(
|
||||
"albumName": a.get("albumName"),
|
||||
"assetCount": a.get("assetCount", 0),
|
||||
"shared": a.get("shared", False),
|
||||
"updatedAt": a.get("updatedAt", ""),
|
||||
}
|
||||
for a in albums
|
||||
]
|
||||
|
||||
@@ -130,6 +130,28 @@ 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),
|
||||
):
|
||||
"""Change current user's password."""
|
||||
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)):
|
||||
"""Check if initial setup is needed (no users exist)."""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Notification dispatch service."""
|
||||
"""Notification dispatch service with full Jinja2 template rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -16,18 +17,125 @@ from ..webhook.client import WebhookClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Default template used when no custom template is configured
|
||||
_env = SandboxedEnvironment(autoescape=False)
|
||||
|
||||
# Default template (Jinja2 syntax) when no config is assigned
|
||||
DEFAULT_TEMPLATE = (
|
||||
"{{ added_count }} new item(s) added to album \"{{ album_name }}\"."
|
||||
"{% if people %}\nPeople: {{ people | join(', ') }}{% endif %}"
|
||||
'{{ added_count }} new item(s) added to album "{{ album_name }}".'
|
||||
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
|
||||
)
|
||||
|
||||
|
||||
def render_template(template_body: str, context: dict[str, Any]) -> str:
|
||||
"""Render a Jinja2 template with the given context."""
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_body)
|
||||
return tmpl.render(**context)
|
||||
def _render(template_str: str, ctx: dict[str, Any]) -> str:
|
||||
"""Render a Jinja2 template string with context. Falls back on error."""
|
||||
try:
|
||||
return _env.from_string(template_str).render(**ctx)
|
||||
except jinja2.TemplateError as e:
|
||||
_LOGGER.error("Template render error: %s", e)
|
||||
return template_str
|
||||
|
||||
|
||||
def build_full_context(
|
||||
event_data: dict[str, Any],
|
||||
template_config: TemplateConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the full template context with all variables from the blueprint.
|
||||
|
||||
This assembles the ~40 variables the blueprint supports:
|
||||
- Direct event fields (album_name, added_count, etc.)
|
||||
- Computed fields (common_date, common_location, people, assets, video_warning)
|
||||
- Formatted sub-templates (asset items, people format, etc.)
|
||||
"""
|
||||
tc = template_config
|
||||
ctx = dict(event_data)
|
||||
|
||||
# People formatting
|
||||
people_list = ctx.get("people", [])
|
||||
if isinstance(people_list, list) and people_list and tc:
|
||||
people_str = ", ".join(str(p) for p in people_list)
|
||||
ctx["people"] = _render(tc.message_people_format, {"people": people_str})
|
||||
elif isinstance(people_list, list):
|
||||
ctx["people"] = ", ".join(str(p) for p in people_list) if people_list else ""
|
||||
else:
|
||||
ctx["people"] = str(people_list) if people_list else ""
|
||||
|
||||
# Asset list formatting
|
||||
added_assets = ctx.get("added_assets", [])
|
||||
if added_assets and tc:
|
||||
date_fmt = tc.date_format or "%d.%m.%Y, %H:%M UTC"
|
||||
|
||||
# Detect common date/location
|
||||
dates = set()
|
||||
locations = set()
|
||||
for a in added_assets:
|
||||
if a.get("created_at"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00"))
|
||||
dates.add(dt.strftime(date_fmt))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
loc_parts = [a.get("city"), a.get("country")]
|
||||
loc = ", ".join(p for p in loc_parts if p)
|
||||
if loc:
|
||||
locations.add(loc)
|
||||
|
||||
common_date = ""
|
||||
if len(dates) == 1:
|
||||
common_date = _render(tc.common_date_template, {"date": next(iter(dates))})
|
||||
ctx["common_date"] = common_date
|
||||
|
||||
common_location = ""
|
||||
if len(locations) == 1:
|
||||
common_location = _render(tc.common_location_template, {"location": next(iter(locations))})
|
||||
ctx["common_location"] = common_location
|
||||
|
||||
# Format individual assets
|
||||
asset_lines = []
|
||||
for a in added_assets:
|
||||
asset_type = a.get("type", "IMAGE")
|
||||
tmpl = tc.message_asset_image if asset_type == "IMAGE" else tc.message_asset_video
|
||||
|
||||
# Per-asset date (only if dates differ)
|
||||
created_if_unique = ""
|
||||
if len(dates) > 1 and a.get("created_at"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00"))
|
||||
created_if_unique = _render(tc.date_if_unique_template, {"date": dt.strftime(date_fmt)})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Per-asset location
|
||||
location_if_unique = ""
|
||||
loc_parts = [a.get("city"), a.get("country")]
|
||||
loc = ", ".join(p for p in loc_parts if p)
|
||||
if loc and len(locations) > 1:
|
||||
location_if_unique = _render(tc.location_if_unique_template, {"location": loc})
|
||||
|
||||
# Favorite indicator
|
||||
fav = tc.favorite_indicator if a.get("is_favorite") else ""
|
||||
|
||||
asset_ctx = {
|
||||
**a,
|
||||
"created_if_unique": created_if_unique,
|
||||
"location_if_unique": location_if_unique,
|
||||
"location": loc,
|
||||
"is_favorite": fav,
|
||||
}
|
||||
asset_lines.append(_render(tmpl, asset_ctx))
|
||||
|
||||
# Assemble assets list
|
||||
assets_str = "".join(asset_lines)
|
||||
ctx["assets"] = _render(tc.message_assets_format, {"assets": assets_str})
|
||||
else:
|
||||
ctx.setdefault("assets", "")
|
||||
ctx.setdefault("common_date", "")
|
||||
ctx.setdefault("common_location", "")
|
||||
|
||||
# Video warning
|
||||
has_videos = any(a.get("type") == "VIDEO" for a in added_assets) if added_assets else False
|
||||
ctx["video_warning"] = (tc.video_warning if tc and has_videos else "")
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
async def send_notification(
|
||||
@@ -36,14 +144,7 @@ async def send_notification(
|
||||
template_config: TemplateConfig | None = None,
|
||||
use_ai_caption: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a notification to a target using event data.
|
||||
|
||||
Args:
|
||||
target: Notification destination (telegram or webhook)
|
||||
event_data: Album change event data (album_name, added_count, etc.)
|
||||
template_config: Optional template config with per-event templates
|
||||
use_ai_caption: If True, generate caption with Claude AI instead of template
|
||||
"""
|
||||
"""Send a notification to a target using event data."""
|
||||
message = None
|
||||
|
||||
# Try AI caption first if enabled
|
||||
@@ -52,11 +153,12 @@ async def send_notification(
|
||||
if is_ai_enabled():
|
||||
message = await generate_caption(event_data)
|
||||
|
||||
# Fall back to template rendering
|
||||
# Render with template engine
|
||||
if message is None:
|
||||
ctx = build_full_context(event_data, template_config)
|
||||
|
||||
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",
|
||||
@@ -66,18 +168,14 @@ async def send_notification(
|
||||
}
|
||||
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:
|
||||
_LOGGER.error("Template rendering failed: %s", e)
|
||||
message = f"Album changed: {event_data.get('album_name', 'unknown')}"
|
||||
|
||||
message = _render(template_body, ctx)
|
||||
|
||||
if target.type == "telegram":
|
||||
return await _send_telegram(target, message, event_data)
|
||||
elif target.type == "webhook":
|
||||
return await _send_webhook(target, message, event_data)
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget) -> dict[str, Any]:
|
||||
@@ -116,7 +214,6 @@ async def _send_telegram(
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
|
||||
# Build assets list from event data for media sending
|
||||
assets = []
|
||||
for asset in event_data.get("added_assets", []):
|
||||
url = asset.get("download_url") or asset.get("url")
|
||||
@@ -142,10 +239,7 @@ async def _send_webhook(
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
payload = {
|
||||
"message": message,
|
||||
"event": event_data,
|
||||
}
|
||||
payload = {"message": message, "event": event_data}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
|
||||
Reference in New Issue
Block a user