feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates

Major architectural improvements:
- Provider-type enforcement: configs validated against provider type at assignment
- TemplateConfig migrated to slot-based pattern (TemplateSlot child table)
- Broadcast targets: TargetReceiver child table for multi-receiver dispatch
- EmailBot: first-class email sender entity with SMTP config, test connection
- CommandTemplateConfig: generic slot-based command response templates
- Provider capability registry: dynamic slot/event/command definitions per provider
- CommandTracker play/pause button matches NotificationTracker style
This commit is contained in:
2026-03-21 16:33:24 +03:00
parent 371ea70756
commit 846d480d38
27 changed files with 2355 additions and 205 deletions
@@ -1,6 +1,11 @@
"""Template configuration CRUD API routes."""
"""Template configuration CRUD API routes.
Template content is stored in TemplateSlot child rows (one per slot_name).
The API exposes slots as a flat dict in create/update/response payloads.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@@ -12,7 +17,7 @@ from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, User
from ..database.models import TemplateConfig, TemplateSlot, User
from ..services.sample_context import _SAMPLE_CONTEXT
_LOGGER = logging.getLogger(__name__)
@@ -25,21 +30,83 @@ class TemplateConfigCreate(BaseModel):
name: str
description: str | None = None
icon: str | None = None
message_assets_added: str | None = None
message_assets_removed: str | None = None
message_collection_renamed: str | None = None
message_collection_deleted: str | None = None
message_sharing_changed: str | None = None
periodic_summary_message: str | None = None
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
class TemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
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
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
"""Load all template slots for a config as a dict."""
result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
async def _save_slots(
session: AsyncSession, config_id: int, slots: 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,
)
)
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,
))
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
"""Build API response dict for a TemplateConfig, including its slots."""
slots = await _load_slots(session, c.id)
return {
"id": c.id,
"user_id": c.user_id,
"provider_type": c.provider_type,
"name": c.name,
"description": c.description,
"icon": c.icon,
"date_format": c.date_format,
"date_only_format": c.date_only_format,
"slots": slots,
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("")
async def list_configs(
provider_type: str | None = None,
@@ -53,7 +120,7 @@ async def list_configs(
if provider_type:
query = query.where(TemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return [_response(c) for c in result.all()]
return [await _response(session, c) for c in result.all()]
@router.get("/variables")
@@ -180,12 +247,22 @@ async def create_config(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
data = {k: v for k, v in body.model_dump().items() if v is not None}
config = TemplateConfig(user_id=user.id, **data)
config = TemplateConfig(
user_id=user.id,
provider_type=body.provider_type,
name=body.name,
description=body.description or "",
icon=body.icon or "",
date_format=body.date_format or "%d.%m.%Y, %H:%M UTC",
date_only_format=body.date_only_format or "%d.%m.%Y",
)
session.add(config)
await session.flush() # get config.id
if body.slots:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return _response(config)
return await _response(session, config)
@router.get("/{config_id}")
@@ -194,7 +271,8 @@ async def get_config(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
return _response(await _get(session, config_id, user.id))
config = await _get(session, config_id, user.id)
return await _response(session, config)
@router.put("/{config_id}")
@@ -205,13 +283,15 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
session.add(config)
if body.slots is not None:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return _response(config)
return await _response(session, config)
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -221,6 +301,12 @@ async def delete_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
# Delete child slots first
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config.id)
)
for slot in slot_result.all():
await session.delete(slot)
await session.delete(config)
await session.commit()
@@ -234,9 +320,10 @@ async def preview_config(
):
"""Render a specific template slot with sample data."""
config = await _get(session, config_id, user.id)
template_body = getattr(config, slot, None)
if template_body is None:
raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}")
slots = await _load_slots(session, config.id)
template_body = slots.get(slot, "")
if not template_body:
raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template")
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_body)
@@ -320,17 +407,3 @@ async def preview_raw(
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}
def _response(c: TemplateConfig) -> dict:
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k not in ("user_id", "created_at")} | {
"user_id": c.user_id,
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config