Simplify templates to pure Jinja2 + CodeMirror editor + variable reference
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user