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:
2026-03-21 01:27:20 +03:00
parent 0dcca2fbe6
commit 1d445f3980
34 changed files with 2777 additions and 582 deletions
@@ -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
@@ -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
@@ -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(),
}