Phase 8: Server health, album filter, Jinja2 engine, password change
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:
2026-03-19 17:27:00 +03:00
parent 431069fbdb
commit 0200b9929f
10 changed files with 269 additions and 52 deletions

View File

@@ -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)