feat: locale-aware notification templates + UX improvements

- Add locale support to notification templates (matching command template
  pattern): TemplateSlot now has locale field with (config_id, slot_name,
  locale) uniqueness, nested API format {slot: {locale: template}}
- Migration merges separate EN/RU system configs into unified per-provider
  configs; seeds create one config per provider with multi-locale slots
- Locale-aware dispatch with EN fallback in NotificationDispatcher
- Frontend locale tabs (EN/RU) on template config editor
- Fix tracking config cards not showing default provider icons
- Global provider filter, search palette, and various UX polish
This commit is contained in:
2026-03-23 19:08:48 +03:00
parent 6a559bfcd2
commit 37388c430c
30 changed files with 628 additions and 318 deletions
@@ -1005,3 +1005,102 @@ async def migrate_command_slot_locale(engine: AsyncEngine) -> None:
"Merged system command template configs (EN=%d, RU=%d) into single config %d",
en_id, ru_id, en_id,
)
async def migrate_notification_slot_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_slot and merge system EN/RU configs per provider.
1. Recreate template_slot with locale column and new unique constraint
2. Backfill locale from parent config's locale (or 'en')
3. For each provider: merge "Default X (RU)" slots into "Default X (EN)" with locale='ru'
4. Rename merged configs, update references, delete orphan RU configs
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_slot"):
return
# Skip if locale column already exists (idempotent)
if await _has_column(conn, "template_slot", "locale"):
return
logger.info("Adding locale column to template_slot and merging system configs")
# Step 1: Recreate table with locale column and new unique constraint
await conn.execute(text(
"CREATE TABLE template_slot_new ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" locale TEXT NOT NULL DEFAULT 'en',"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name, locale)"
")"
))
# Step 2: Copy existing data, deriving locale from parent config
await conn.execute(text(
"INSERT INTO template_slot_new (id, config_id, slot_name, locale, template) "
"SELECT s.id, s.config_id, s.slot_name, "
" CASE WHEN c.locale != '' THEN c.locale ELSE 'en' END, "
" s.template "
"FROM template_slot s "
"LEFT JOIN template_config c ON s.config_id = c.id"
))
await conn.execute(text("DROP TABLE template_slot"))
await conn.execute(text(
"ALTER TABLE template_slot_new RENAME TO template_slot"
))
# Step 3: Merge system EN/RU configs per provider type
providers = (await conn.execute(text(
"SELECT DISTINCT provider_type FROM template_config WHERE user_id = 0"
))).fetchall()
for (provider_type,) in providers:
en_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'en' OR name LIKE '%(EN)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
ru_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'ru' OR name LIKE '%(RU)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
if en_row and ru_row and en_row[0] != ru_row[0]:
en_id, ru_id = en_row[0], ru_row[0]
# Move RU slots to the EN config (they already have locale='ru')
await conn.execute(text(
"UPDATE template_slot SET config_id = :en_id "
"WHERE config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Update notification_tracker_target references from RU to EN
if await _has_table(conn, "notification_tracker_target"):
await conn.execute(text(
"UPDATE notification_tracker_target SET template_config_id = :en_id "
"WHERE template_config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Delete the orphan RU config
await conn.execute(text(
"DELETE FROM template_config WHERE id = :ru_id"
), {"ru_id": ru_id})
# Rename the merged config (strip locale suffix)
label = provider_type.capitalize()
await conn.execute(text(
"UPDATE template_config SET name = :name, "
"description = :desc, locale = '' "
"WHERE id = :en_id"
), {"name": f"Default {label}", "desc": f"Default {label} templates", "en_id": en_id})
logger.info(
"Merged system notification template configs for %s (EN=%d, RU=%d) into %d",
provider_type, en_id, ru_id, en_id,
)
@@ -213,14 +213,15 @@ class TemplateConfig(SQLModel, table=True):
class TemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific slot within a TemplateConfig.
"""One Jinja2 template for a specific slot and locale within a TemplateConfig.
Slot names are provider-specific (e.g. 'message_assets_added' for Immich).
Each (config, slot, locale) triple holds a separate template.
"""
__tablename__ = "template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_template_slot"),
UniqueConstraint("config_id", "slot_name", "locale", name="uq_template_slot_locale"),
)
id: int | None = Field(default=None, primary_key=True)
@@ -231,6 +232,7 @@ class TemplateSlot(SQLModel, table=True):
)
slot_name: str
locale: str = Field(default="en")
template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -30,7 +30,11 @@ async def _seed_provider_template(
provider_type: str,
label: str,
) -> None:
"""Seed templates for a single provider type across all locales."""
"""Seed templates for a single provider type across all locales.
Creates a single TemplateConfig per provider with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
from notify_bridge_core.templates.defaults import load_default_templates
result = await session.exec(
@@ -40,80 +44,42 @@ async def _seed_provider_template(
)
)
configs = result.all()
existing_locales = {
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
for c in configs
}
if not configs:
config = TemplateConfig(
user_id=0,
provider_type=provider_type,
name=f"Default {label}",
description=f"Default {label} templates",
)
session.add(config)
await session.flush()
else:
config = configs[0]
for locale in ("en", "ru"):
slots = load_default_templates(locale, provider_type=provider_type)
if not slots:
continue
if locale not in existing_locales:
now = datetime.now(timezone.utc).isoformat()
name = f"Default {label} ({locale.upper()})"
desc = f"Default {label} templates ({locale.upper()})"
# Get column names to build INSERT with defaults for legacy cols
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values: dict[str, object] = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = provider_type
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = "" # empty string for legacy columns
cols_str = ", ".join(values.keys())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == locale,
)
)
config_id = (await session.execute(
text("SELECT last_insert_rowid()")
)).scalar()
for slot_name, template_text in slots.items():
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
else:
config = existing_locales[locale]
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
async def _seed_provider_command_template(