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
@@ -0,0 +1,147 @@
"""Target receiver management API routes (nested under targets)."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-receivers"])
class ReceiverCreate(BaseModel):
name: str = ""
config: dict[str, Any] = {}
enabled: bool = True
class ReceiverUpdate(BaseModel):
name: str | None = None
config: dict[str, Any] | None = None
enabled: bool | None = None
def _receiver_key(target_type: str, config: dict[str, Any]) -> str:
"""Derive a unique key for deduplication from receiver config."""
if target_type == "telegram":
return str(config.get("chat_id", ""))
elif target_type == "webhook":
return config.get("url", "")
elif target_type == "email":
return config.get("email", "")
return ""
@router.get("")
async def list_receivers(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
return [_response(r) for r in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_receiver(
target_id: int,
body: ReceiverCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
key = _receiver_key(target.type, body.config)
if not key:
raise HTTPException(status_code=400, detail="Receiver config must include a delivery endpoint (chat_id, url, or email)")
# Check for duplicate
existing = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
if existing.first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Receiver already exists for this target")
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=body.config,
receiver_key=key,
enabled=body.enabled,
)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.put("/{receiver_id}")
async def update_receiver(
target_id: int,
receiver_id: int,
body: ReceiverUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(receiver, field, value)
# Update receiver_key if config changed
if body.config is not None:
target = await session.get(NotificationTarget, target_id)
receiver.receiver_key = _receiver_key(target.type, receiver.config)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
receiver_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
await session.delete(receiver)
await session.commit()
def _response(r: TargetReceiver) -> dict:
return {
"id": r.id,
"target_id": r.target_id,
"name": r.name,
"config": dict(r.config),
"receiver_key": r.receiver_key,
"enabled": r.enabled,
"created_at": r.created_at.isoformat(),
}
async def _get_user_target(session: AsyncSession, target_id: int, user_id: int) -> NotificationTarget:
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user_id:
raise HTTPException(status_code=404, detail="Target not found")
return target