Files
notify-bridge/packages/server/src/notify_bridge_server/api/command_trackers.py
T
alexei.dolgolyov b5ffab7ece
Release / release (push) Successful in 59s
fix(command_trackers): allow system-shared command configs (user_id=0)
Creating or updating a command tracker failed with 404
"Command config not found" when the selected config was a system
default (seeded with user_id=0). The LIST endpoint already accepts
both owned and system-shared rows via
  or_(CommandConfig.user_id == user.id, CommandConfig.user_id == 0)
so the frontend legitimately offered a user_id=0 option — the POST
and PATCH handlers then rejected it.

Align the create/update checks with the list behavior:
  config.user_id not in (user.id, 0)
2026-04-21 21:02:33 +03:00

409 lines
13 KiB
Python

"""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,
)
from .helpers import get_owned_entity
_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 is accessible (owned or system-shared)
config = await session.get(CommandConfig, body.command_config_id)
if not config or config.user_id not in (user.id, 0):
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)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
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 accessibility 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 not in (user.id, 0):
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)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
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)
# Mark affected bots dirty before deleting (chain breaks after deletion)
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.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)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# 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)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# 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 [await _listener_response(session, 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)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(body.listener_id)
return await _listener_response(session, 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)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(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"] = [await _listener_response(session, l) for l in lr.all()]
return resp
async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -> dict:
name = ""
if l.listener_type == "telegram_bot":
bot = await session.get(TelegramBot, l.listener_id)
if bot:
name = bot.name
return {
"id": l.id,
"command_tracker_id": l.command_tracker_id,
"listener_type": l.listener_type,
"listener_id": l.listener_id,
"name": name,
"created_at": l.created_at.isoformat(),
}
async def _get_user_tracker(
session: AsyncSession, tracker_id: int, user_id: int
) -> CommandTracker:
return await get_owned_entity(
session, CommandTracker, tracker_id, user_id,
not_found_msg="Command tracker not found",
)