feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots

- Discord, Slack, ntfy, Matrix notification target types with clients and dispatch
- MatrixBot model + API + frontend in Bots tab
- Command template system fully wired into all handler commands
- Default command templates seeded (EN/RU, 14 slots each)
- Command template editor with variables reference including child fields
- Delete protection on all 10 entity types (409 with consumer details)
- Provider type selector on template config forms
- Target type selector as dropdown with all 7 types
- Response template selector on command config form
- CLAUDE.md: mandatory server restart rule, child properties rule
This commit is contained in:
2026-03-21 20:36:12 +03:00
parent 846d480d38
commit 3e3a6f0777
64 changed files with 1861 additions and 180 deletions
@@ -0,0 +1,179 @@
"""Delete protection — prevents deletion of entities that are in use.
Each check function returns a list of consumer descriptions. If non-empty,
the entity cannot be deleted.
"""
from fastapi import HTTPException, status
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
TargetReceiver,
TelegramChat,
)
def raise_if_used(consumers: list[str], entity_name: str) -> None:
"""Raise 409 Conflict if the entity has consumers."""
if consumers:
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
"""Check if a ServiceProvider is used by any trackers."""
consumers = []
result = await session.exec(
select(NotificationTracker).where(NotificationTracker.provider_id == provider_id)
)
for t in result.all():
consumers.append(f"Notification Tracker: {t.name}")
result = await session.exec(
select(CommandTracker).where(CommandTracker.provider_id == provider_id)
)
for t in result.all():
consumers.append(f"Command Tracker: {t.name}")
return consumers
async def check_telegram_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if a TelegramBot is used by any targets or command listeners."""
consumers = []
# Check notification targets with this bot in config
result = await session.exec(select(NotificationTarget))
for t in result.all():
if t.config.get("bot_id") == bot_id or t.config.get("bot_token"):
# Need to verify it's actually this bot
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
# Check command tracker listeners
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.listener_type == "telegram_bot",
CommandTrackerListener.listener_id == bot_id,
)
)
for listener in result.all():
tracker = await session.get(CommandTracker, listener.command_tracker_id)
name = tracker.name if tracker else f"#{listener.command_tracker_id}"
consumers.append(f"Command Tracker Listener: {name}")
return consumers
async def check_email_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if an EmailBot is used by any targets."""
consumers = []
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "email")
)
for t in result.all():
if t.config.get("email_bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
return consumers
async def check_matrix_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if a MatrixBot is used by any targets."""
consumers = []
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "matrix")
)
for t in result.all():
if t.config.get("matrix_bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
return consumers
async def check_tracking_config(session: AsyncSession, config_id: int) -> list[str]:
"""Check if a TrackingConfig is used by any tracker-target links."""
consumers = []
result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracking_config_id == config_id
)
)
for tt in result.all():
tracker = await session.get(NotificationTracker, tt.tracker_id)
target = await session.get(NotificationTarget, tt.target_id)
tracker_name = tracker.name if tracker else f"#{tt.tracker_id}"
target_name = target.name if target else f"#{tt.target_id}"
consumers.append(f"Tracker Link: {tracker_name}{target_name}")
return consumers
async def check_template_config(session: AsyncSession, config_id: int) -> list[str]:
"""Check if a TemplateConfig is used by any tracker-target links."""
consumers = []
result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.template_config_id == config_id
)
)
for tt in result.all():
tracker = await session.get(NotificationTracker, tt.tracker_id)
target = await session.get(NotificationTarget, tt.target_id)
tracker_name = tracker.name if tracker else f"#{tt.tracker_id}"
target_name = target.name if target else f"#{tt.target_id}"
consumers.append(f"Tracker Link: {tracker_name}{target_name}")
return consumers
async def check_command_template_config(session: AsyncSession, config_id: int) -> list[str]:
"""Check if a CommandTemplateConfig is used by any command configs."""
consumers = []
result = await session.exec(
select(CommandConfig).where(
CommandConfig.command_template_config_id == config_id
)
)
for c in result.all():
consumers.append(f"Command Config: {c.name}")
return consumers
async def check_command_config(session: AsyncSession, config_id: int) -> list[str]:
"""Check if a CommandConfig is used by any command trackers."""
consumers = []
result = await session.exec(
select(CommandTracker).where(CommandTracker.command_config_id == config_id)
)
for t in result.all():
consumers.append(f"Command Tracker: {t.name}")
return consumers
async def check_notification_target(session: AsyncSession, target_id: int) -> list[str]:
"""Check if a NotificationTarget is used by any tracker-target links."""
consumers = []
result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id == target_id
)
)
for tt in result.all():
tracker = await session.get(NotificationTracker, tt.tracker_id)
name = tracker.name if tracker else f"#{tt.tracker_id}"
consumers.append(f"Notification Tracker: {name}")
return consumers
async def check_notification_tracker(session: AsyncSession, tracker_id: int) -> list[str]:
"""Check if a NotificationTracker has any linked targets."""
consumers = []
result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
for tt in result.all():
target = await session.get(NotificationTarget, tt.target_id)
name = target.name if target else f"#{tt.target_id}"
consumers.append(f"Linked Target: {name}")
return consumers