All checks were successful
Validate / Hassfest (push) Successful in 32s
- Error line highlighting in JinjaEditor (red background on error line)
- Backend returns error_line from TemplateSyntaxError
- Localized syntax error messages with line number
- Renamed {{ }} button to "Variables" (localized)
- Localized all template variable descriptions (EN/RU)
- Added t() fallback parameter for graceful degradation
- Page transition animation (fade) to prevent content stacking
- Added syntaxError/line i18n keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
"""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 jinja2 import TemplateSyntaxError, UndefinedError
|
|
|
|
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),
|
|
):
|
|
from sqlalchemy import or_
|
|
result = await session.exec(
|
|
select(TemplateConfig).where(
|
|
or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0)
|
|
)
|
|
)
|
|
return [_response(c) for c in result.all()]
|
|
|
|
|
|
@router.get("/variables")
|
|
async def get_template_variables():
|
|
"""Get the variable reference for all template slots."""
|
|
from .template_vars import TEMPLATE_VARIABLES
|
|
return TEMPLATE_VARIABLES
|
|
|
|
|
|
@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}")
|
|
|
|
|
|
class PreviewRequest(BaseModel):
|
|
template: str
|
|
|
|
|
|
@router.post("/preview-raw")
|
|
async def preview_raw(
|
|
body: PreviewRequest,
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Render arbitrary Jinja2 template text with sample data. For live preview while editing."""
|
|
try:
|
|
env = SandboxedEnvironment(autoescape=False)
|
|
tmpl = env.from_string(body.template)
|
|
rendered = tmpl.render(**_SAMPLE_CONTEXT)
|
|
return {"rendered": rendered}
|
|
except TemplateSyntaxError as e:
|
|
return {
|
|
"rendered": None,
|
|
"error": e.message,
|
|
"error_line": e.lineno,
|
|
}
|
|
except UndefinedError as e:
|
|
return {"rendered": None, "error": str(e), "error_line": None}
|
|
except Exception as e:
|
|
return {"rendered": None, "error": str(e), "error_line": None}
|
|
|
|
|
|
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 and config.user_id != 0):
|
|
raise HTTPException(status_code=404, detail="Template config not found")
|
|
return config
|