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:
@@ -226,9 +226,19 @@ async def test_notification_tracker_target(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == template_config.id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
TemplateSlot.locale == "en",
|
||||
)
|
||||
)
|
||||
slot = slot_result.first()
|
||||
if not slot:
|
||||
# Fallback: any locale
|
||||
slot_result2 = await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == template_config.id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
)
|
||||
)
|
||||
slot = slot_result2.first()
|
||||
template_str = slot.template if slot else ""
|
||||
|
||||
# Load provider and tracker data eagerly before aiohttp context
|
||||
|
||||
@@ -369,8 +369,22 @@ async def list_people(
|
||||
provider.config.get("url", ""),
|
||||
provider.config.get("api_key", ""),
|
||||
)
|
||||
people_dict = await client.get_people()
|
||||
return [{"id": pid, "name": name} for pid, name in people_dict.items()]
|
||||
try:
|
||||
async with http_session.get(
|
||||
f"{client.url}/api/people",
|
||||
headers={"x-api-key": client.api_key},
|
||||
ssl=False,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
people_list = data.get("people", data) if isinstance(data, dict) else data
|
||||
return [
|
||||
{"id": p["id"], "name": p.get("name", "")}
|
||||
for p in people_list
|
||||
if p.get("name")
|
||||
]
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to fetch people: %s", e)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -296,6 +296,37 @@ async def test_chat(
|
||||
return await client.send_message(chat_id, message)
|
||||
|
||||
|
||||
class ChatUpdate(BaseModel):
|
||||
language_code: str | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
@router.put("/{bot_id}/chats/{chat_db_id}")
|
||||
async def update_chat(
|
||||
bot_id: int,
|
||||
chat_db_id: int,
|
||||
body: ChatUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a chat's language_code or title."""
|
||||
await _get_user_bot(session, bot_id, user.id)
|
||||
chat = await session.get(TelegramChat, chat_db_id)
|
||||
if not chat or chat.bot_id != bot_id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
for key, value in updates.items():
|
||||
setattr(chat, key, value)
|
||||
session.add(chat)
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return {
|
||||
"id": chat.id, "bot_id": chat.bot_id, "chat_id": chat.chat_id,
|
||||
"title": chat.title, "type": chat.chat_type, "username": chat.username,
|
||||
"language_code": chat.language_code, "discovered_at": chat.discovered_at.isoformat() if chat.discovered_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat(
|
||||
bot_id: int,
|
||||
|
||||
@@ -32,7 +32,7 @@ class TemplateConfigCreate(BaseModel):
|
||||
icon: str | None = None
|
||||
date_format: str | None = None
|
||||
date_only_format: str | None = None
|
||||
slots: dict[str, str] = {} # slot_name -> template text
|
||||
slots: dict[str, dict[str, str]] = {} # slot_name -> {locale -> template text}
|
||||
|
||||
|
||||
class TemplateConfigUpdate(BaseModel):
|
||||
@@ -41,42 +41,48 @@ class TemplateConfigUpdate(BaseModel):
|
||||
icon: str | None = None
|
||||
date_format: str | None = None
|
||||
date_only_format: str | None = None
|
||||
slots: dict[str, str] | None = None # partial update: only provided slots change
|
||||
slots: dict[str, dict[str, str]] | None = None # partial update
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
|
||||
"""Load all template slots for a config as a dict."""
|
||||
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
|
||||
"""Load all template slots for a config as {slot_name: {locale: template}}."""
|
||||
result = await session.exec(
|
||||
select(TemplateSlot).where(TemplateSlot.config_id == config_id)
|
||||
)
|
||||
return {s.slot_name: s.template for s in result.all()}
|
||||
slots: dict[str, dict[str, str]] = {}
|
||||
for s in result.all():
|
||||
slots.setdefault(s.slot_name, {})[s.locale] = s.template
|
||||
return slots
|
||||
|
||||
|
||||
async def _save_slots(
|
||||
session: AsyncSession, config_id: int, slots: dict[str, str]
|
||||
session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]
|
||||
) -> None:
|
||||
"""Create or update template slots for a config."""
|
||||
for slot_name, template_text in slots.items():
|
||||
result = await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == config_id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
"""Create or update template slots for a config (locale-aware)."""
|
||||
for slot_name, locale_map in slots.items():
|
||||
for locale, template_text in locale_map.items():
|
||||
result = await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
TemplateSlot.config_id == config_id,
|
||||
TemplateSlot.slot_name == slot_name,
|
||||
TemplateSlot.locale == locale,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = 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,
|
||||
))
|
||||
existing = result.first()
|
||||
if existing:
|
||||
existing.template = template_text
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TemplateSlot(
|
||||
config_id=config_id,
|
||||
slot_name=slot_name,
|
||||
locale=locale,
|
||||
template=template_text,
|
||||
))
|
||||
|
||||
|
||||
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
|
||||
@@ -322,13 +328,15 @@ async def delete_config(
|
||||
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)
|
||||
template_body = slots.get(slot, "")
|
||||
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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -47,7 +47,7 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
# Run data migrations (idempotent)
|
||||
from .database.engine import get_engine
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale, migrate_notification_slot_locale
|
||||
engine = get_engine()
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(engine)
|
||||
@@ -57,6 +57,7 @@ async def lifespan(app: FastAPI):
|
||||
await migrate_template_locale(engine)
|
||||
await migrate_receivers_from_config(engine)
|
||||
await migrate_command_slot_locale(engine)
|
||||
await migrate_notification_slot_locale(engine)
|
||||
from .database.seeds import seed_all
|
||||
await seed_all()
|
||||
# Configure webhook secret from DB setting (falls back to env var)
|
||||
|
||||
@@ -129,18 +129,18 @@ async def load_link_data(
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
|
||||
template_config = None
|
||||
template_slots: dict[str, str] | None = None
|
||||
template_slots: dict[str, dict[str, str]] | None = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
if template_config:
|
||||
slot_result = await session.exec(
|
||||
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
|
||||
)
|
||||
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
|
||||
template_slots = {}
|
||||
for slot_name, tmpl_text in raw_slots.items():
|
||||
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
|
||||
template_slots[event_key] = tmpl_text
|
||||
raw_slots: dict[str, dict[str, str]] = {}
|
||||
for s in slot_result.all():
|
||||
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
|
||||
raw_slots.setdefault(event_key, {})[s.locale] = s.template
|
||||
template_slots = raw_slots
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject chat_action for Telegram targets
|
||||
|
||||
Reference in New Issue
Block a user