a7a2b4efa4
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.
533 lines
23 KiB
Python
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)
|