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
@@ -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: