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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user