feat: entity relationship refactor — notification trackers, command system, chat actions
Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/ CommandTracker/CommandTrackerListener entities for decoupled command handling. Commands now resolve through CommandTracker→CommandConfig instead of TelegramBot.commands_config. Smart ref-counted bot polling based on active listeners. Add chat_action to telegram targets. Full frontend CRUD pages for command configs and command trackers. Idempotent SQLite migrations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
"""Command tracker and listener management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import func, 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 (
|
||||
CommandConfig,
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
User,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/command-trackers", tags=["command-trackers"])
|
||||
|
||||
|
||||
class CommandTrackerCreate(BaseModel):
|
||||
provider_id: int
|
||||
command_config_id: int
|
||||
name: str
|
||||
icon: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class CommandTrackerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
enabled: bool | None = None
|
||||
command_config_id: int | None = None
|
||||
|
||||
|
||||
class ListenerCreate(BaseModel):
|
||||
listener_type: str
|
||||
listener_id: int
|
||||
|
||||
|
||||
# --- Command Tracker CRUD ---
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_command_trackers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all command trackers for the current user, with listener counts."""
|
||||
result = await session.exec(
|
||||
select(CommandTracker).where(CommandTracker.user_id == user.id)
|
||||
)
|
||||
trackers = result.all()
|
||||
return [await _tracker_response(session, t) for t in trackers]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_command_tracker(
|
||||
body: CommandTrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new command tracker."""
|
||||
# Validate provider exists and user owns it
|
||||
provider = await session.get(ServiceProvider, body.provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# Validate command config exists and user owns it
|
||||
config = await session.get(CommandConfig, body.command_config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
|
||||
# Validate provider_type matches
|
||||
if config.provider_type != provider.type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
|
||||
)
|
||||
|
||||
tracker = CommandTracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
async def get_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single command tracker with its listeners."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
return await _tracker_response(session, tracker, include_listeners=True)
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
async def update_command_tracker(
|
||||
tracker_id: int,
|
||||
body: CommandTrackerUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
# If changing command_config_id, validate ownership and provider_type match
|
||||
if "command_config_id" in updates and updates["command_config_id"] is not None:
|
||||
config = await session.get(CommandConfig, updates["command_config_id"])
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
provider = await session.get(ServiceProvider, tracker.provider_id)
|
||||
if provider and config.provider_type != provider.type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(tracker, field, value)
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a command tracker and cascade delete its listeners."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Delete associated listeners, collecting bot IDs for polling cleanup
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
bot_ids_to_check: set[int] = set()
|
||||
for listener in result.all():
|
||||
if listener.listener_type == "telegram_bot":
|
||||
bot_ids_to_check.add(listener.listener_id)
|
||||
await session.delete(listener)
|
||||
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
|
||||
# Stop polling for bots that may no longer be needed
|
||||
if bot_ids_to_check:
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
for bot_id in bot_ids_to_check:
|
||||
await stop_bot_if_unused(bot_id)
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/enable")
|
||||
async def enable_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Enable a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tracker.enabled = True
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
|
||||
# Start polling for any telegram bot listeners
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
)
|
||||
)
|
||||
from ..services.telegram_poller import start_bot_if_needed
|
||||
for listener in lr.all():
|
||||
await start_bot_if_needed(listener.listener_id)
|
||||
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/disable")
|
||||
async def disable_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Disable a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tracker.enabled = False
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
|
||||
# Stop polling for any telegram bot listeners that are no longer needed
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
)
|
||||
)
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
for listener in lr.all():
|
||||
await stop_bot_if_unused(listener.listener_id)
|
||||
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
# --- Listener Management ---
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/listeners")
|
||||
async def list_listeners(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all listeners for a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
return [_listener_response(l) for l in result.all()]
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/listeners", status_code=status.HTTP_201_CREATED)
|
||||
async def add_listener(
|
||||
tracker_id: int,
|
||||
body: ListenerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Add a listener to a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Validate listener exists and user owns it
|
||||
if body.listener_type == "telegram_bot":
|
||||
bot = await session.get(TelegramBot, body.listener_id)
|
||||
if not bot or bot.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Telegram bot not found")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported listener type: {body.listener_type}",
|
||||
)
|
||||
|
||||
# Check for duplicate
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == body.listener_type,
|
||||
CommandTrackerListener.listener_id == body.listener_id,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Listener is already linked to this command tracker",
|
||||
)
|
||||
|
||||
listener = CommandTrackerListener(
|
||||
command_tracker_id=tracker_id,
|
||||
listener_type=body.listener_type,
|
||||
listener_id=body.listener_id,
|
||||
)
|
||||
session.add(listener)
|
||||
await session.commit()
|
||||
await session.refresh(listener)
|
||||
|
||||
# Start polling for this bot if needed
|
||||
if body.listener_type == "telegram_bot":
|
||||
from ..services.telegram_poller import start_bot_if_needed
|
||||
await start_bot_if_needed(body.listener_id)
|
||||
|
||||
return _listener_response(listener)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_listener(
|
||||
tracker_id: int,
|
||||
listener_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Remove a listener from a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
listener = await session.get(CommandTrackerListener, listener_id)
|
||||
if not listener or listener.command_tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Listener not found")
|
||||
|
||||
removed_type = listener.listener_type
|
||||
removed_id = listener.listener_id
|
||||
|
||||
await session.delete(listener)
|
||||
await session.commit()
|
||||
|
||||
# Stop polling for this bot if no longer needed
|
||||
if removed_type == "telegram_bot":
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
await stop_bot_if_unused(removed_id)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
async def _tracker_response(
|
||||
session: AsyncSession, t: CommandTracker, include_listeners: bool = False
|
||||
) -> dict:
|
||||
"""Build command tracker response."""
|
||||
# Get listener count
|
||||
result = await session.exec(
|
||||
select(func.count()).select_from(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == t.id
|
||||
)
|
||||
)
|
||||
listeners_count = result.one()
|
||||
|
||||
resp = {
|
||||
"id": t.id,
|
||||
"user_id": t.user_id,
|
||||
"provider_id": t.provider_id,
|
||||
"command_config_id": t.command_config_id,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"enabled": t.enabled,
|
||||
"listeners_count": listeners_count,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
if include_listeners:
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == t.id
|
||||
)
|
||||
)
|
||||
resp["listeners"] = [_listener_response(l) for l in lr.all()]
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def _listener_response(l: CommandTrackerListener) -> dict:
|
||||
return {
|
||||
"id": l.id,
|
||||
"command_tracker_id": l.command_tracker_id,
|
||||
"listener_type": l.listener_type,
|
||||
"listener_id": l.listener_id,
|
||||
"created_at": l.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> CommandTracker:
|
||||
tracker = await session.get(CommandTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Command tracker not found")
|
||||
return tracker
|
||||
Reference in New Issue
Block a user