Simplify templates to pure Jinja2 + CodeMirror editor + variable reference
Some checks failed
Validate / Hassfest (push) Has been cancelled

Major template system overhaul:
- TemplateConfig simplified from 21 fields to 9: removed all sub-templates
  (asset_image, asset_video, assets_format, people_format, etc.)
  Users write full Jinja2 with {% for %}, {% if %} inline.
- Default EN/RU templates seeded on first startup (user_id=0, system-owned)
  with proper Jinja2 loops over added_assets, people, albums.
- build_full_context() simplified: passes raw data directly to Jinja2
  instead of pre-rendering sub-templates.
- CodeMirror editor for template slots (HTML syntax highlighting,
  line wrapping, dark theme support via oneDark).
- Variable reference API: GET /api/template-configs/variables returns
  per-slot variable descriptions + asset_fields for loop contexts.
- Variable reference modal in UI: click "{{ }} Variables" next to any
  slot to see available variables with Jinja2 syntax examples.
- Route ordering fix: /variables registered before /{config_id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:57:51 +03:00
parent bc8fda5984
commit 0bb4d8a949
9 changed files with 791 additions and 181 deletions

View File

@@ -69,12 +69,22 @@ 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(TemplateConfig.user_id == user.id)
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,

View File

@@ -0,0 +1,87 @@
"""Template variable reference for all template slots."""
TEMPLATE_VARIABLES: dict[str, dict] = {
"message_assets_added": {
"description": "Notification when new assets are added to an album",
"variables": {
"album_name": "Album name",
"album_url": "Public share URL (if available)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"change_type": "Type of change (assets_added)",
"people": "List of detected people names (use {{ people | join(', ') }})",
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
"shared": "Whether album is shared (true/false)",
"video_warning": "Video size warning text (if videos present)",
},
"asset_fields": {
"filename": "Original filename",
"type": "IMAGE or VIDEO",
"created_at": "Creation date/time (ISO 8601)",
"owner": "Owner display name",
"description": "User description or EXIF description",
"url": "Public viewer URL",
"download_url": "Direct download URL",
"photo_url": "Preview image URL (images only)",
"playback_url": "Video playback URL (videos only)",
"is_favorite": "Whether asset is favorited (boolean)",
"rating": "Star rating (1-5 or null)",
"city": "City name",
"state": "State/region name",
"country": "Country name",
"people": "People detected in this asset (list)",
},
},
"message_assets_removed": {
"description": "Notification when assets are removed",
"variables": {
"album_name": "Album name",
"album_url": "Public share URL",
"removed_count": "Number of assets removed",
"removed_assets": "List of removed asset IDs",
"change_type": "Type of change (assets_removed)",
},
},
"message_album_renamed": {
"description": "Notification when album is renamed",
"variables": {
"old_name": "Previous album name",
"new_name": "New album name",
"album_url": "Public share URL",
},
},
"message_album_deleted": {
"description": "Notification when album is deleted",
"variables": {
"album_name": "Album name",
},
},
"periodic_summary_message": {
"description": "Periodic album summary",
"variables": {
"albums": "List of album dicts (use {% for album in albums %})",
},
"album_fields": {
"name": "Album name",
"asset_count": "Number of assets",
"url": "Public share URL",
},
},
"scheduled_assets_message": {
"description": "Scheduled asset delivery",
"variables": {
"album_name": "Album name (empty in combined mode)",
"album_url": "Public share URL",
"assets": "List of asset dicts (use {% for asset in assets %})",
},
"asset_fields": "(same as message_assets_added.asset_fields)",
},
"memory_mode_message": {
"description": "On This Day memory notification",
"variables": {
"album_name": "Album name (empty in combined mode)",
"assets": "List of asset dicts (use {% for asset in assets %})",
},
"asset_fields": "(same as message_assets_added.asset_fields)",
},
}

View File

@@ -108,7 +108,11 @@ class TrackingConfig(SQLModel, table=True):
class TemplateConfig(SQLModel, table=True):
"""Message template configuration: all template slots from the blueprint."""
"""Message template configuration with full Jinja2 templates.
Each slot is a complete Jinja2 template with access to loops, conditionals,
and filters. No sub-templates needed -- use {% for %}, {% if %} inline.
"""
__tablename__ = "template_config"
@@ -117,49 +121,19 @@ class TemplateConfig(SQLModel, table=True):
name: str # e.g. "Default EN", "Default RU"
icon: str = Field(default="")
# Event messages
message_assets_added: str = Field(
default='📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}'
)
message_assets_removed: str = Field(
default='🗑️ {removed_count} photo(s) removed from album "{album_name}".'
)
message_album_renamed: str = Field(
default='✏️ Album "{old_name}" renamed to "{new_name}".'
)
message_album_deleted: str = Field(
default='🗑️ Album "{album_name}" was deleted.'
)
# Event-driven notification templates (full Jinja2)
message_assets_added: str = Field(default="")
message_assets_removed: str = Field(default="")
message_album_renamed: str = Field(default="")
message_album_deleted: str = Field(default="")
# Asset item formatting
message_asset_image: str = Field(default="\n • 🖼️ {filename}")
message_asset_video: str = Field(default="\n • 🎬 {filename}")
message_assets_format: str = Field(default="\nAssets:{assets}")
message_assets_more: str = Field(default="\n • ...and {more_count} more")
message_people_format: str = Field(default=" People: {people}.")
# Scheduled notification templates (full Jinja2)
periodic_summary_message: str = Field(default="")
scheduled_assets_message: str = Field(default="")
memory_mode_message: str = Field(default="")
# Date/location formatting
# Settings
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
common_date_template: str = Field(default=" from {date}")
date_if_unique_template: str = Field(default=" ({date})")
location_format: str = Field(default="{city}, {country}")
common_location_template: str = Field(default=" in {location}")
location_if_unique_template: str = Field(default=" 📍 {location}")
favorite_indicator: str = Field(default="❤️")
# Scheduled notification templates
periodic_summary_message: str = Field(
default="📋 Tracked Albums Summary ({album_count} albums):{albums}"
)
periodic_album_template: str = Field(
default="\n{album_name}: {album_url}"
)
scheduled_assets_message: str = Field(
default='📸 Here are some photos from album "{album_name}":{assets}'
)
memory_mode_message: str = Field(default="📅 On this day:{assets}")
# Telegram-specific
video_warning: str = Field(
default="\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit."
)
@@ -167,6 +141,81 @@ class TemplateConfig(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
# --- Default template content (EN) ---
DEFAULT_TEMPLATE_EN = {
"message_assets_added": """📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".\
{% if people %}
👤 {{ people | join(", ") }}{% endif %}\
{% if added_assets %}
{% for asset in added_assets %}\
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
{% if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}\
{% if asset.is_favorite %} ❤️{% endif %}
{% endfor %}\
{% endif %}\
{{ video_warning }}""",
"message_assets_removed": '🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".',
"message_album_renamed": '✏️ Album "{{ old_name }}" renamed to "{{ new_name }}".',
"message_album_deleted": '🗑️ Album "{{ album_name }}" was deleted.',
"periodic_summary_message": """📋 Tracked Albums Summary ({{ albums | length }} albums):\
{% for album in albums %}
{{ album.name }}: {{ album.asset_count }} assets{% if album.url %} — {{ album.url }}{% endif %}\
{% endfor %}""",
"scheduled_assets_message": """📸 Photos from "{{ album_name }}":\
{% for asset in assets %}
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
{% endfor %}""",
"memory_mode_message": """📅 On this day:\
{% for asset in assets %}
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\
{% endfor %}""",
}
# --- Default template content (RU) ---
DEFAULT_TEMPLATE_RU = {
"message_assets_added": """📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".\
{% if people %}
👤 {{ people | join(", ") }}{% endif %}\
{% if added_assets %}
{% for asset in added_assets %}\
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
{% if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}\
{% if asset.is_favorite %} ❤️{% endif %}
{% endfor %}\
{% endif %}\
{{ video_warning }}""",
"message_assets_removed": '🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".',
"message_album_renamed": '✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".',
"message_album_deleted": '🗑️ Альбом "{{ album_name }}" был удалён.',
"periodic_summary_message": """📋 Сводка альбомов ({{ albums | length }}):\
{% for album in albums %}
{{ album.name }}: {{ album.asset_count }} файлов{% if album.url %} — {{ album.url }}{% endif %}\
{% endfor %}""",
"scheduled_assets_message": """📸 Фото из "{{ album_name }}":\
{% for asset in assets %}
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
{% endfor %}""",
"memory_mode_message": """📅 В этот день:\
{% for asset in assets %}
{% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\
{% endfor %}""",
}
class NotificationTarget(SQLModel, table=True):
"""Notification destination with tracking and template config references."""

View File

@@ -33,6 +33,33 @@ logging.basicConfig(
_LOGGER = logging.getLogger(__name__)
async def _seed_default_templates():
"""Create default EN/RU template configs if none exist."""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import (
TemplateConfig,
DEFAULT_TEMPLATE_EN,
DEFAULT_TEMPLATE_RU,
)
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(select(func.count()).select_from(TemplateConfig))
count = result.one()
if count > 0:
return
# user_id=0 means system-owned (available to all users)
en = TemplateConfig(user_id=0, name="Default EN", icon="mdiTranslate", **DEFAULT_TEMPLATE_EN)
ru = TemplateConfig(user_id=0, name="По умолчанию RU", icon="mdiTranslate", **DEFAULT_TEMPLATE_RU)
session.add(en)
session.add(ru)
await session.commit()
_LOGGER.info("Seeded default EN and RU template configs")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown."""
@@ -41,6 +68,9 @@ async def lifespan(app: FastAPI):
await init_db()
_LOGGER.info("Database initialized at %s", settings.effective_database_url)
# Seed default templates if none exist
await _seed_default_templates()
await start_scheduler()
yield

View File

@@ -39,101 +39,21 @@ 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.
"""Build template context by passing raw data directly to Jinja2.
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.)
The templates use {% for %}, {% if %} etc. to handle formatting,
so no pre-rendering of sub-templates is needed.
"""
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", "")
# Ensure lists are actual lists (not strings)
if isinstance(ctx.get("people"), str):
ctx["people"] = [ctx["people"]] if ctx["people"] else []
# Video warning
added_assets = ctx.get("added_assets", [])
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 "")
ctx["video_warning"] = (template_config.video_warning if template_config and has_videos else "")
return ctx