Files
notify-bridge/packages/server/src/notify_bridge_server/api/template_configs.py
T
alexei.dolgolyov a7a2b4efa4 feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
2026-04-22 01:13:11 +03:00

533 lines
23 KiB
Python

"""Template configuration CRUD API routes.
Template content is stored in TemplateSlot child rows (one per slot_name).
The API exposes slots as a flat dict in create/update/response payloads.
"""
import logging
from typing import Any
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, TemplateSlot, User
from ..services.sample_context import _SAMPLE_CONTEXT
from .slot_helpers import load_slots, render_template_preview, save_slots
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
class TemplateConfigCreate(BaseModel):
provider_type: str
name: str
description: str | None = None
icon: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, dict[str, str]] = {} # slot_name -> {locale -> template text}
class TemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
icon: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, dict[str, str]] | None = None # partial update
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
return await load_slots(session, TemplateSlot, config_id)
async def _save_slots(
session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]
) -> None:
await save_slots(session, TemplateSlot, config_id, slots)
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
"""Build API response dict for a TemplateConfig, including its slots."""
slots = await _load_slots(session, c.id)
return {
"id": c.id,
"user_id": c.user_id,
"provider_type": c.provider_type,
"name": c.name,
"description": c.description,
"icon": c.icon,
"date_format": c.date_format,
"date_only_format": c.date_only_format,
"slots": slots,
"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 and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("")
async def list_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from sqlalchemy import or_
query = select(TemplateConfig).where(
or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0)
)
if provider_type:
query = query.where(TemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return [await _response(session, c) for c in result.all()]
@router.get("/variables")
async def get_template_variables(
user: User = Depends(get_current_user),
):
"""Get template variable reference grouped by slot.
Returns a dict keyed by template slot name, each containing:
- description: what the slot is for
- variables: dict of variable_name -> description
- asset_fields: dict of field_name -> description (for slots with assets)
- album_fields: dict of field_name -> description (for slots with albums)
"""
# Core event variables available in all event templates
event_vars = {
"collection_id": "Collection ID (UUID)",
"collection_name": "Collection name",
"collection_url": "Public share URL (empty if not shared)",
"public_url": "Public share link URL (empty if no link exists)",
"protected_url": "Password-protected share link URL (empty if none)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"people": "Detected people names (list, use {{ people | join(', ') }})",
"shared": "Whether collection is shared (boolean)",
"photo_count": "Total photo count in album",
"video_count": "Total video count in album",
"owner": "Album owner name",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
"has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
"max_video_size": "Target video size limit in bytes (null if no limit)",
"max_video_size_mb": "Target video size limit in MB (null if no limit)",
# Provider-specific aliases
"album_name": "Alias for collection_name",
"album_id": "Alias for collection_id",
"album_url": "Alias for collection_url (Immich) or album product URL (Google Photos)",
}
rename_vars = {
**event_vars,
"old_name": "Previous name (rename events)",
"new_name": "New name (rename events)",
}
sharing_vars = {
**event_vars,
"old_shared": "Was shared before change (boolean)",
"new_shared": "Is shared after change (boolean)",
}
asset_fields = {
"id": "Asset ID (UUID)",
"filename": "Original filename",
"type": "IMAGE or VIDEO",
"created_at": "Creation date/time (ISO 8601)",
"owner": "Owner display name",
"description": "User or EXIF description",
"people": "People detected in this asset (list)",
"is_favorite": "Whether asset is favorited (boolean)",
"rating": "Star rating (1-5 or null)",
"city": "City name",
"state": "State/region name",
"country": "Country name",
"file_size": "Original asset size in bytes (null if unknown)",
"playback_size": "Size in bytes of the media we actually upload — for Immich videos this is the transcoded /video/playback (null for photos or when unknown)",
"oversized": "Whether the asset's playback_size exceeds the target's size limit (boolean, videos only)",
"public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
"playback_url": "Video playback URL (videos only, if shared)",
}
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
}
scheduled_vars = {
"date": "Current date string",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
}
return {
"message_assets_added": {
"description": "Notification when new assets are added to a collection",
"variables": {
**event_vars,
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
},
"asset_fields": asset_fields,
},
"message_assets_removed": {
"description": "Notification when assets are removed from a collection",
"variables": {**event_vars, "removed_assets": "List of removed asset IDs (strings)"},
},
"message_collection_renamed": {
"description": "Notification when a collection is renamed",
"variables": rename_vars,
},
"message_collection_deleted": {
"description": "Notification when a collection is deleted",
"variables": event_vars,
},
"message_sharing_changed": {
"description": "Notification when sharing status changes",
"variables": sharing_vars,
},
"periodic_summary_message": {
"description": "Periodic summary of all tracked collections",
"variables": {**scheduled_vars, "collections": "List of collection dicts (use {% for album in collections %})"},
"album_fields": album_fields,
},
"scheduled_assets_message": {
"description": "Scheduled asset delivery (daily photo picks)",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"asset_fields": asset_fields,
},
"memory_mode_message": {
"description": "\"On This Day\" memories from previous years",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"asset_fields": asset_fields,
},
# --- Generic Webhook slots ---
**_webhook_variables(),
# --- Gitea slots ---
**_gitea_variables(),
# --- Planka slots ---
**_planka_variables(),
# --- NUT (UPS) slots ---
**_nut_variables(),
# --- Scheduler slots ---
"message_scheduled_message": {
"description": "Notification for scheduled message events",
"variables": {
"schedule_name": "Name of the schedule that fired",
"fire_count": "How many times this tracker has fired",
"current_date": "Current date (formatted)",
"current_time": "Current time (formatted)",
"current_datetime": "Current date and time (formatted)",
},
},
}
def _webhook_variables() -> dict:
return {
"message_webhook_received": {
"description": "Incoming webhook event notification",
"variables": {
"service_name": "Provider instance name",
"event_type_raw": "Raw event type from payload (or 'webhook_received')",
"collection_name": "Collection extracted from payload via collection_path (or empty)",
"source_ip": "IP address of the webhook sender",
"raw_payload": "Full JSON payload as dict (use raw_payload.field or raw_payload | tojson)",
"timestamp": "When the webhook was received",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
},
},
}
def _gitea_variables() -> dict:
common = {
"sender": "Username who triggered the event",
"sender_name": "Display name of the sender",
"repo_name": "Repository full name (owner/repo)",
"repo_url": "Repository URL",
}
return {
"message_push": {
"description": "Code pushed to repository",
"variables": {**common, "branch": "Branch name", "commit_count": "Number of commits",
"compare_url": "Comparison URL", "commits": "List of commit dicts"},
},
"message_issue_opened": {
"description": "Issue opened",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "issue_body": "Issue body text", "issue_labels": "Labels list"},
},
"message_issue_closed": {
"description": "Issue closed",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "issue_state": "Issue state"},
},
"message_issue_commented": {
"description": "Comment on issue",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "comment_body": "Comment text",
"comment_url": "Comment URL", "comment_author": "Comment author"},
},
"message_pr_opened": {
"description": "Pull request opened",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_body": "PR body text",
"pr_base": "Base branch", "pr_head": "Head branch", "pr_labels": "Labels list"},
},
"message_pr_closed": {
"description": "Pull request closed",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_state": "PR state", "pr_merged": "Whether PR was merged"},
},
"message_pr_merged": {
"description": "Pull request merged",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_base": "Base branch", "pr_head": "Head branch"},
},
"message_pr_commented": {
"description": "Comment on pull request",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "comment_body": "Comment text",
"comment_url": "Comment URL", "comment_author": "Comment author"},
},
"message_release_published": {
"description": "Release published",
"variables": {**common, "release_tag": "Release tag", "release_name": "Release name",
"release_url": "Release URL", "release_body": "Release notes",
"release_draft": "Is draft (boolean)", "release_prerelease": "Is prerelease (boolean)"},
},
}
def _planka_variables() -> dict:
common = {
"sender": "Username who triggered the event",
"sender_name": "Display name of the sender",
"board_name": "Board name",
"board_id": "Board ID",
"board_url": "Board URL",
}
card = {**common, "card_name": "Card name", "card_id": "Card ID", "card_url": "Card URL"}
return {
"message_card_created": {"description": "Card created", "variables": {**card, "list_name": "List name", "card_description": "Card description"}},
"message_card_updated": {"description": "Card updated", "variables": {**card, "card_description": "Card description", "card_due_date": "Due date"}},
"message_card_moved": {"description": "Card moved between lists", "variables": {**card, "old_list_name": "Previous list", "new_list_name": "New list"}},
"message_card_deleted": {"description": "Card deleted", "variables": card},
"message_card_commented": {"description": "Comment added to card", "variables": {**card, "comment_text": "Comment text"}},
"message_comment_updated": {"description": "Comment updated", "variables": {**card, "comment_text": "Updated comment text"}},
"message_board_created": {"description": "Board created", "variables": common},
"message_board_updated": {"description": "Board updated", "variables": common},
"message_board_deleted": {"description": "Board deleted", "variables": common},
"message_list_created": {"description": "List created", "variables": {**common, "list_name": "List name"}},
"message_list_updated": {"description": "List updated", "variables": {**common, "list_name": "List name"}},
"message_list_deleted": {"description": "List deleted", "variables": {**common, "list_name": "List name"}},
"message_attachment_created": {"description": "Attachment added", "variables": {**card, "attachment_name": "Attachment filename"}},
"message_card_label_added": {"description": "Label added to card", "variables": {**card, "label_name": "Label name", "label_color": "Label color"}},
"message_task_completed": {"description": "Task completed", "variables": {**card, "task_name": "Task name", "task_completed": "Completed (boolean)"}},
}
def _nut_variables() -> dict:
common = {
"ups_name": "UPS device name",
"ups_model": "UPS hardware model",
"ups_manufacturer": "UPS manufacturer",
"battery_charge": "Battery charge percentage",
"battery_runtime": "Estimated runtime (formatted)",
"battery_runtime_seconds": "Estimated runtime in seconds",
"ups_load": "UPS load percentage",
"ups_status": "Raw status flags (e.g. OL, OB, LB)",
"input_voltage": "Input voltage",
"output_voltage": "Output voltage",
"event_description": "Human-readable event description",
"previous_status": "Previous UPS status flags",
}
return {
"message_ups_online": {"description": "UPS back on mains power", "variables": common},
"message_ups_on_battery": {"description": "UPS switched to battery", "variables": common},
"message_ups_low_battery": {"description": "Battery critically low", "variables": common},
"message_ups_battery_restored": {"description": "Battery charge recovered", "variables": common},
"message_ups_comms_lost": {"description": "Communication with UPS lost", "variables": {"ups_name": common["ups_name"], "previous_status": common["previous_status"], "event_description": common["event_description"]}},
"message_ups_comms_restored": {"description": "Communication restored", "variables": common},
"message_ups_replace_battery": {"description": "Battery needs replacement", "variables": common},
"message_ups_overload": {"description": "UPS load exceeded capacity", "variables": common},
}
@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),
):
config = TemplateConfig(
user_id=user.id,
provider_type=body.provider_type,
name=body.name,
description=body.description or "",
icon=body.icon or "",
date_format=body.date_format or "%d.%m.%Y, %H:%M UTC",
date_only_format=body.date_only_format or "%d.%m.%Y",
)
session.add(config)
await session.flush() # get config.id
if body.slots:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return await _response(session, config)
@router.get("/{config_id}")
async def get_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
return await _response(session, config)
@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)
if config.user_id == 0 and user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot modify system default configs")
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
session.add(config)
if body.slots is not None:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return await _response(session, 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),
):
from .delete_protection import check_template_config, raise_if_used
config = await _get(session, config_id, user.id)
raise_if_used(await check_template_config(session, config.id), config.name)
# Delete child slots first
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config.id)
)
for slot in slot_result.all():
await session.delete(slot)
await session.delete(config)
await session.commit()
@router.post("/{config_id}/preview")
async def preview_config(
config_id: int,
slot: str = "message_assets_added",
locale: str = "en",
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)
slots = await _load_slots(session, config.id)
locale_map = slots.get(slot, {})
template_body = locale_map.get(locale) or locale_map.get("en", "")
if not template_body:
raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template")
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}")
class DateFormatPreviewRequest(BaseModel):
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@router.post("/preview-date-format")
async def preview_date_format(
body: DateFormatPreviewRequest,
user: User = Depends(get_current_user),
):
"""Preview what date/datetime formats look like with sample data."""
from datetime import datetime, timezone
sample_dt = datetime(2026, 3, 19, 14, 30, 0, tzinfo=timezone.utc)
sample_date = datetime(2026, 3, 19)
result: dict[str, str | None] = {}
for key, fmt, sample in [
("date_format", body.date_format, sample_dt),
("date_only_format", body.date_only_format, sample_date),
]:
try:
result[key] = sample.strftime(fmt)
except (ValueError, TypeError):
result[key] = None
return result
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # telegram, webhook, email, discord, slack, ntfy, matrix
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@router.post("/preview-raw")
async def preview_raw(
body: PreviewRequest,
user: User = Depends(get_current_user),
):
"""Render arbitrary Jinja2 template text with sample data.
Two-pass validation:
1. Parse with default Undefined (catches syntax errors)
2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
"""
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
return render_template_preview(body.template, ctx)