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,151 @@
|
||||
"""Command config management API routes."""
|
||||
|
||||
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 CommandConfig, CommandTracker, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/command-configs", tags=["command-configs"])
|
||||
|
||||
|
||||
class CommandConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
name: str
|
||||
icon: str = ""
|
||||
enabled_commands: list[str] = []
|
||||
locale: str = "en"
|
||||
response_mode: str = "media"
|
||||
default_count: int = 5
|
||||
rate_limits: dict[str, Any] = {}
|
||||
|
||||
|
||||
class CommandConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
enabled_commands: list[str] | None = None
|
||||
locale: str | None = None
|
||||
response_mode: str | None = None
|
||||
default_count: int | None = None
|
||||
rate_limits: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_command_configs(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all command configs for the current user."""
|
||||
result = await session.exec(
|
||||
select(CommandConfig).where(CommandConfig.user_id == user.id)
|
||||
)
|
||||
return [_config_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_command_config(
|
||||
body: CommandConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new command config."""
|
||||
# Validate provider_type
|
||||
valid_types = ("immich",)
|
||||
if body.provider_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid provider_type. Must be one of: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
config = CommandConfig(user_id=user.id, **body.model_dump())
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_command_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single command config."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_command_config(
|
||||
config_id: int,
|
||||
body: CommandConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a command config."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(config, field, value)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_command_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a command config. Fails if in use by any command tracker."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
|
||||
# Check if any command tracker references this config
|
||||
result = await session.exec(
|
||||
select(CommandTracker).where(CommandTracker.command_config_id == config_id)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete: command config is in use by a command tracker",
|
||||
)
|
||||
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _config_response(c: CommandConfig) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"provider_type": c.provider_type,
|
||||
"name": c.name,
|
||||
"icon": c.icon,
|
||||
"enabled_commands": c.enabled_commands or [],
|
||||
"locale": c.locale,
|
||||
"response_mode": c.response_mode,
|
||||
"default_count": c.default_count,
|
||||
"rate_limits": c.rate_limits or {},
|
||||
"created_at": c.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_config(
|
||||
session: AsyncSession, config_id: int, user_id: int
|
||||
) -> CommandConfig:
|
||||
config = await session.get(CommandConfig, config_id)
|
||||
if not config or config.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
return config
|
||||
@@ -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
|
||||
+28
-31
@@ -1,4 +1,4 @@
|
||||
"""Tracker-Target link management API routes."""
|
||||
"""Notification tracker-target link management API routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -12,10 +12,10 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
@@ -23,50 +23,48 @@ from ..services.notifier import send_real_data_notification, send_test_notificat
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
|
||||
router = APIRouter(prefix="/api/notification-trackers/{tracker_id}/targets", tags=["notification-tracker-targets"])
|
||||
|
||||
|
||||
class TrackerTargetCreate(BaseModel):
|
||||
class NotificationTrackerTargetCreate(BaseModel):
|
||||
target_id: int
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool = True
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TrackerTargetUpdate(BaseModel):
|
||||
class NotificationTrackerTargetUpdate(BaseModel):
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tracker_targets(
|
||||
async def list_notification_tracker_targets(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all target links for a tracker."""
|
||||
"""List all target links for a notification tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
return [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker_target(
|
||||
async def create_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
body: TrackerTargetCreate,
|
||||
body: NotificationTrackerTargetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Link a target to a tracker with per-link configuration."""
|
||||
"""Link a target to a notification tracker with per-link configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Validate target exists and belongs to user
|
||||
@@ -76,9 +74,9 @@ async def create_tracker_target(
|
||||
|
||||
# Check for duplicate link
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker_id,
|
||||
TrackerTarget.target_id == body.target_id,
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.tracker_id == tracker_id,
|
||||
NotificationTrackerTarget.target_id == body.target_id,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
@@ -97,7 +95,7 @@ async def create_tracker_target(
|
||||
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
tt = TrackerTarget(tracker_id=tracker_id, **body.model_dump())
|
||||
tt = NotificationTrackerTarget(tracker_id=tracker_id, **body.model_dump())
|
||||
session.add(tt)
|
||||
await session.commit()
|
||||
await session.refresh(tt)
|
||||
@@ -105,16 +103,16 @@ async def create_tracker_target(
|
||||
|
||||
|
||||
@router.put("/{tracker_target_id}")
|
||||
async def update_tracker_target(
|
||||
async def update_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
body: TrackerTargetUpdate,
|
||||
body: NotificationTrackerTargetUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a tracker-target link's configuration."""
|
||||
"""Update a notification tracker-target link's configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
@@ -138,15 +136,15 @@ async def update_tracker_target(
|
||||
|
||||
|
||||
@router.delete("/{tracker_target_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker_target(
|
||||
async def delete_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Remove a target link from a tracker."""
|
||||
"""Remove a target link from a notification tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
await session.delete(tt)
|
||||
@@ -154,7 +152,7 @@ async def delete_tracker_target(
|
||||
|
||||
|
||||
@router.post("/{tracker_target_id}/test/{test_type}")
|
||||
async def test_tracker_target(
|
||||
async def test_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
test_type: str,
|
||||
@@ -171,7 +169,7 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=400, detail=f"Invalid test type. Must be one of: {', '.join(valid_types)}")
|
||||
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
@@ -224,7 +222,7 @@ async def test_tracker_target(
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
|
||||
async def _tt_response(session: AsyncSession, tt: NotificationTrackerTarget) -> dict:
|
||||
"""Build tracker-target response with target details."""
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
return {
|
||||
@@ -239,15 +237,14 @@ async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
|
||||
"enabled": tt.enabled,
|
||||
"quiet_hours_start": tt.quiet_hours_start,
|
||||
"quiet_hours_end": tt.quiet_hours_end,
|
||||
"commands_config": tt.commands_config,
|
||||
"created_at": tt.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
) -> NotificationTracker:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
+26
-26
@@ -1,4 +1,4 @@
|
||||
"""Tracker management API routes."""
|
||||
"""Notification tracker management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -11,22 +11,21 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTracker,
|
||||
NotificationTrackerState,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
User,
|
||||
)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
from ..services.watcher import check_tracker
|
||||
from .tracker_targets import _tt_response
|
||||
from .notification_tracker_targets import _tt_response
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
router = APIRouter(prefix="/api/notification-trackers", tags=["notification-trackers"])
|
||||
|
||||
|
||||
class TrackerCreate(BaseModel):
|
||||
class NotificationTrackerCreate(BaseModel):
|
||||
provider_id: int
|
||||
name: str
|
||||
icon: str = ""
|
||||
@@ -36,7 +35,7 @@ class TrackerCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TrackerUpdate(BaseModel):
|
||||
class NotificationTrackerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
collection_ids: list[str] | None = None
|
||||
@@ -46,20 +45,20 @@ class TrackerUpdate(BaseModel):
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trackers(
|
||||
async def list_notification_trackers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
select(NotificationTracker).where(NotificationTracker.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_tracker(
|
||||
body: TrackerCreate,
|
||||
async def create_notification_tracker(
|
||||
body: NotificationTrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -67,7 +66,7 @@ async def create_tracker(
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
tracker = Tracker(user_id=user.id, **body.model_dump())
|
||||
tracker = NotificationTracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
@@ -77,7 +76,7 @@ async def create_tracker(
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
async def get_tracker(
|
||||
async def get_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -87,9 +86,9 @@ async def get_tracker(
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
async def update_tracker(
|
||||
async def update_notification_tracker(
|
||||
tracker_id: int,
|
||||
body: TrackerUpdate,
|
||||
body: NotificationTrackerUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -107,7 +106,7 @@ async def update_tracker(
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker(
|
||||
async def delete_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -115,13 +114,13 @@ async def delete_tracker(
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
# Delete associated tracker state
|
||||
state_result = await session.exec(
|
||||
select(TrackerState).where(TrackerState.tracker_id == tracker_id)
|
||||
select(NotificationTrackerState).where(NotificationTrackerState.tracker_id == tracker_id)
|
||||
)
|
||||
for ts in state_result.all():
|
||||
await session.delete(ts)
|
||||
@@ -138,18 +137,19 @@ async def delete_tracker(
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/trigger")
|
||||
async def trigger_tracker(
|
||||
async def trigger_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.watcher import check_tracker
|
||||
result = await check_tracker(tracker.id)
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
async def notification_tracker_history(
|
||||
tracker_id: int,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -175,10 +175,10 @@ async def tracker_history(
|
||||
]
|
||||
|
||||
|
||||
async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> dict:
|
||||
"""Build tracker response with nested tracker_targets."""
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == t.id)
|
||||
)
|
||||
tracker_targets = [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
@@ -198,8 +198,8 @@ async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
) -> NotificationTracker:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
@@ -8,7 +8,7 @@ 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, ServiceProvider, Tracker, EventLog, User
|
||||
from ..database.models import NotificationTarget, NotificationTracker, ServiceProvider, EventLog, User
|
||||
|
||||
router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
|
||||
@@ -31,7 +31,7 @@ async def get_status(
|
||||
)).one()
|
||||
|
||||
trackers_result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
trackers = trackers_result.all()
|
||||
active_count = sum(1 for t in trackers if t.enabled)
|
||||
@@ -43,8 +43,8 @@ async def get_status(
|
||||
# Build events query with filters
|
||||
events_query = (
|
||||
select(EventLog)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
|
||||
if event_type:
|
||||
@@ -110,8 +110,8 @@ async def get_event_chart(
|
||||
EventLog.event_type,
|
||||
func.count().label("total"),
|
||||
)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
.group_by(day_col, EventLog.event_type)
|
||||
.order_by(day_col)
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
|
||||
from ..database.models import NotificationTarget, NotificationTrackerTarget, TelegramBot, TelegramChat, User
|
||||
from ..services.notifier import send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -23,12 +23,14 @@ class TargetCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = ""
|
||||
config: dict[str, Any] = {}
|
||||
chat_action: str | None = None
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
chat_action: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -80,6 +82,7 @@ async def create_target(
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
chat_action=body.chat_action,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
@@ -125,7 +128,7 @@ async def delete_target(
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id == target_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
@@ -153,6 +156,7 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"chat_action": target.chat_action,
|
||||
"created_at": target.created_at.isoformat(),
|
||||
}
|
||||
# Attach resolved chat name for telegram targets
|
||||
|
||||
@@ -34,7 +34,6 @@ class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
update_mode: str | None = None
|
||||
commands_config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -86,9 +85,6 @@ async def update_bot(
|
||||
bot.name = body.name
|
||||
if body.icon is not None:
|
||||
bot.icon = body.icon
|
||||
if body.commands_config is not None:
|
||||
bot.commands_config = body.commands_config
|
||||
|
||||
# Handle mode switching
|
||||
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
||||
if body.update_mode == "webhook":
|
||||
@@ -403,7 +399,6 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
"bot_id": b.bot_id,
|
||||
"webhook_path_id": b.webhook_path_id,
|
||||
"update_mode": b.update_mode or "polling",
|
||||
"commands_config": b.commands_config or {},
|
||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user