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:
@@ -1,8 +1,10 @@
|
||||
"""Data migrations for schema changes.
|
||||
|
||||
Handles converting legacy JSON-array relationships to proper junction tables.
|
||||
Handles converting legacy JSON-array relationships to proper junction tables,
|
||||
and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -11,97 +13,133 @@ from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _has_column(conn, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a SQLite table."""
|
||||
cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text(f"PRAGMA table_info('{table}')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
return column in cols
|
||||
|
||||
|
||||
async def _has_table(conn, table: str) -> bool:
|
||||
"""Check if a table exists in the SQLite database."""
|
||||
result = await conn.run_sync(
|
||||
lambda sync_conn: sync_conn.execute(
|
||||
text(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name=:name"
|
||||
),
|
||||
{"name": table},
|
||||
).fetchone()
|
||||
)
|
||||
return result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy schema migrations (pre-Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
|
||||
async with engine.begin() as conn:
|
||||
# Helper to check if column exists
|
||||
async def _has_column(table: str, column: str) -> bool:
|
||||
cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text(f"PRAGMA table_info('{table}')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
return column in cols
|
||||
# --- Tracker table (may still be named "tracker" or already renamed) ---
|
||||
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
|
||||
|
||||
# Add batch_duration to tracker if missing
|
||||
if not await _has_column("tracker", "batch_duration"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added batch_duration column to tracker table")
|
||||
if await _has_table(conn, tracker_table):
|
||||
if not await _has_column(conn, tracker_table, "batch_duration"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {tracker_table} ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added batch_duration column to %s table", tracker_table)
|
||||
|
||||
# Add enriched fields to event_log if missing
|
||||
for col, sql in [
|
||||
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
|
||||
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
|
||||
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if not await _has_column("event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
if await _has_table(conn, "event_log"):
|
||||
for col, sql in [
|
||||
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
|
||||
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
|
||||
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if not await _has_column(conn, "event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
|
||||
# Add commands_config to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "commands_config"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
|
||||
)
|
||||
logger.info("Added commands_config column to telegram_bot table")
|
||||
|
||||
# Add webhook_path_id to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "webhook_path_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added webhook_path_id column to telegram_bot table")
|
||||
# Backfill existing bots with unique IDs
|
||||
import uuid
|
||||
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
|
||||
for bot in bots:
|
||||
if await _has_table(conn, "telegram_bot"):
|
||||
if not await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
await conn.execute(
|
||||
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
|
||||
{"wid": uuid.uuid4().hex, "bid": bot[0]},
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
|
||||
)
|
||||
if bots:
|
||||
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
||||
logger.info("Added commands_config column to telegram_bot table")
|
||||
|
||||
# Add webhook_path_id to telegram_bot if missing
|
||||
if not await _has_column(conn, "telegram_bot", "webhook_path_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added webhook_path_id column to telegram_bot table")
|
||||
# Backfill existing bots with unique IDs
|
||||
import uuid
|
||||
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
|
||||
for bot in bots:
|
||||
await conn.execute(
|
||||
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
|
||||
{"wid": uuid.uuid4().hex, "bid": bot[0]},
|
||||
)
|
||||
if bots:
|
||||
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
||||
|
||||
# Add update_mode to telegram_bot if missing
|
||||
if not await _has_column(conn, "telegram_bot", "update_mode"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
||||
)
|
||||
logger.info("Added update_mode column to telegram_bot table")
|
||||
|
||||
# Add date_only_format to template_config if missing
|
||||
if not await _has_column("template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
|
||||
)
|
||||
logger.info("Added date_only_format column to template_config table")
|
||||
|
||||
# Add update_mode to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "update_mode"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
||||
)
|
||||
logger.info("Added update_mode column to telegram_bot table")
|
||||
if await _has_table(conn, "template_config"):
|
||||
if not await _has_column(conn, "template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
|
||||
)
|
||||
logger.info("Added date_only_format column to template_config table")
|
||||
|
||||
# Add memory_source to tracking_config if missing
|
||||
if not await _has_column("tracking_config", "memory_source"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
|
||||
)
|
||||
logger.info("Added memory_source column to tracking_config table")
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
if not await _has_column(conn, "tracking_config", "memory_source"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
|
||||
)
|
||||
logger.info("Added memory_source column to tracking_config table")
|
||||
|
||||
# Add collection_name and shared to tracker_state if missing
|
||||
if not await _has_column("tracker_state", "collection_name"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added collection_name column to tracker_state table")
|
||||
if not await _has_column("tracker_state", "shared"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to tracker_state table")
|
||||
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
|
||||
if await _has_table(conn, state_table):
|
||||
if not await _has_column(conn, state_table, "collection_name"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN collection_name TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added collection_name column to %s table", state_table)
|
||||
if not await _has_column(conn, state_table, "shared"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to %s table", state_table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy tracker_target migration (pre-Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
|
||||
|
||||
@@ -114,36 +152,42 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
Idempotent: skips if legacy columns don't exist or data already migrated.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
# Check if legacy target_ids column exists on tracker table
|
||||
columns = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('tracker')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "target_ids" not in columns:
|
||||
# Determine which table name exists (pre- or post-rename)
|
||||
if await _has_table(conn, "tracker"):
|
||||
tracker_table = "tracker"
|
||||
tt_table = "tracker_target"
|
||||
tracker_id_col = "tracker_id"
|
||||
elif await _has_table(conn, "notification_tracker"):
|
||||
tracker_table = "notification_tracker"
|
||||
tt_table = "notification_tracker_target"
|
||||
tracker_id_col = "notification_tracker_id"
|
||||
else:
|
||||
logger.debug("No tracker table found — skipping migration")
|
||||
return
|
||||
|
||||
# Check if legacy target_ids column exists
|
||||
if not await _has_column(conn, tracker_table, "target_ids"):
|
||||
logger.debug("No legacy target_ids column found — skipping migration")
|
||||
return
|
||||
|
||||
# Check if tracker_target table already has data (previous migration ran)
|
||||
tt_count = (
|
||||
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
|
||||
).scalar()
|
||||
if tt_count and tt_count > 0:
|
||||
logger.debug(
|
||||
"tracker_target table already has %d rows — skipping migration",
|
||||
tt_count,
|
||||
)
|
||||
return
|
||||
# Check if junction table already has data
|
||||
if await _has_table(conn, tt_table):
|
||||
tt_count = (
|
||||
await conn.execute(text(f"SELECT COUNT(*) FROM {tt_table}"))
|
||||
).scalar()
|
||||
if tt_count and tt_count > 0:
|
||||
logger.debug(
|
||||
"%s table already has %d rows — skipping migration",
|
||||
tt_table, tt_count,
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy data
|
||||
trackers = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, target_ids, tracking_config_id, "
|
||||
"quiet_hours_start, quiet_hours_end FROM tracker"
|
||||
f"SELECT id, target_ids, tracking_config_id, "
|
||||
f"quiet_hours_start, quiet_hours_end FROM {tracker_table}"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
@@ -154,20 +198,10 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
|
||||
# Load template_config_id from targets (legacy field)
|
||||
target_template_map: dict[int, int | None] = {}
|
||||
target_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('notification_target')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "template_config_id" in target_cols:
|
||||
if await _has_column(conn, "notification_target", "template_config_id"):
|
||||
targets = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, template_config_id FROM notification_target"
|
||||
)
|
||||
text("SELECT id, template_config_id FROM notification_target")
|
||||
)
|
||||
).fetchall()
|
||||
for t in targets:
|
||||
@@ -175,15 +209,7 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
|
||||
# Load commands_config from telegram_bots (legacy field)
|
||||
bot_commands_map: dict[int, str | None] = {}
|
||||
bot_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('telegram_bot')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "commands_config" in bot_cols:
|
||||
if await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
bots = (
|
||||
await conn.execute(
|
||||
text("SELECT id, commands_config FROM telegram_bot")
|
||||
@@ -195,8 +221,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
# Build target → bot mapping for commands_config migration
|
||||
target_bot_map: dict[int, int] = {}
|
||||
if bot_commands_map:
|
||||
import json
|
||||
|
||||
tgt_rows = (
|
||||
await conn.execute(
|
||||
text("SELECT id, config FROM notification_target WHERE type='telegram'")
|
||||
@@ -207,35 +231,21 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
|
||||
if cfg and "bot_token" in cfg:
|
||||
for bot_id, _ in bot_commands_map.items():
|
||||
bot_row = (
|
||||
bot_token_row = (
|
||||
await conn.execute(
|
||||
text("SELECT id FROM telegram_bot WHERE id=:bid"),
|
||||
text("SELECT token FROM telegram_bot WHERE id=:bid"),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_row:
|
||||
# Match by checking if this target uses this bot's token
|
||||
bot_token_row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT token FROM telegram_bot WHERE id=:bid"
|
||||
),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_token_row and bot_token_row[0] == cfg.get(
|
||||
"bot_token"
|
||||
):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
if bot_token_row and bot_token_row[0] == cfg.get("bot_token"):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to match bot token for target %s", tgt[0],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Create TrackerTarget rows
|
||||
import json
|
||||
|
||||
# Create junction rows
|
||||
migrated = 0
|
||||
for tracker in trackers:
|
||||
tracker_id = tracker[0]
|
||||
@@ -244,7 +254,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
quiet_hours_start = tracker[3]
|
||||
quiet_hours_end = tracker[4]
|
||||
|
||||
# Parse target_ids JSON
|
||||
if isinstance(raw_target_ids, str):
|
||||
try:
|
||||
target_ids = json.loads(raw_target_ids)
|
||||
@@ -258,25 +267,22 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
for target_id in target_ids:
|
||||
template_config_id = target_template_map.get(target_id)
|
||||
|
||||
# Get commands_config if this is a telegram target with a known bot
|
||||
commands_config = None
|
||||
if target_id in target_bot_map:
|
||||
bot_id = target_bot_map[target_id]
|
||||
raw_cmd = bot_commands_map.get(bot_id)
|
||||
if raw_cmd:
|
||||
commands_config = (
|
||||
raw_cmd
|
||||
if isinstance(raw_cmd, str)
|
||||
else json.dumps(raw_cmd)
|
||||
raw_cmd if isinstance(raw_cmd, str) else json.dumps(raw_cmd)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO tracker_target "
|
||||
"(tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, "
|
||||
"quiet_hours_end, commands_config) "
|
||||
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
|
||||
f"INSERT INTO {tt_table} "
|
||||
f"({tracker_id_col}, target_id, tracking_config_id, "
|
||||
f"template_config_id, enabled, quiet_hours_start, "
|
||||
f"quiet_hours_end, commands_config) "
|
||||
f"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
|
||||
),
|
||||
{
|
||||
"tid": tracker_id,
|
||||
@@ -291,3 +297,243 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
migrated += 1
|
||||
|
||||
logger.info("Migrated %d tracker-target links", migrated)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: Entity refactor migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_entity_refactor(engine: AsyncEngine) -> None:
|
||||
"""Phase 1 entity refactor — rename tables, add columns, create new tables.
|
||||
|
||||
Fully idempotent: every operation checks preconditions before acting.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Rename table: tracker → notification_tracker
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker") and not await _has_table(conn, "notification_tracker"):
|
||||
await conn.execute(text("ALTER TABLE tracker RENAME TO notification_tracker"))
|
||||
logger.info("Renamed table tracker → notification_tracker")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Rename table: tracker_target → notification_tracker_target
|
||||
# and rename column tracker_id → notification_tracker_id
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker_target") and not await _has_table(conn, "notification_tracker_target"):
|
||||
# SQLite doesn't support RENAME COLUMN in older versions, so we
|
||||
# recreate the table with the new column name.
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE notification_tracker_target ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" target_id INTEGER REFERENCES notification_target(id),"
|
||||
" tracking_config_id INTEGER REFERENCES tracking_config(id),"
|
||||
" template_config_id INTEGER REFERENCES template_config(id),"
|
||||
" enabled INTEGER DEFAULT 1,"
|
||||
" quiet_hours_start TEXT,"
|
||||
" quiet_hours_end TEXT,"
|
||||
" commands_config TEXT,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO notification_tracker_target "
|
||||
"(id, notification_tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
|
||||
"commands_config, created_at) "
|
||||
"SELECT id, tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
|
||||
"commands_config, created_at "
|
||||
"FROM tracker_target"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE tracker_target"))
|
||||
logger.info("Renamed table tracker_target → notification_tracker_target (with column rename tracker_id → notification_tracker_id)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Rename table: tracker_state → notification_tracker_state
|
||||
# and rename column tracker_id → notification_tracker_id
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker_state") and not await _has_table(conn, "notification_tracker_state"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE notification_tracker_state ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" collection_id TEXT,"
|
||||
" collection_name TEXT DEFAULT '',"
|
||||
" shared INTEGER DEFAULT 0,"
|
||||
" asset_ids TEXT,"
|
||||
" pending_asset_ids TEXT,"
|
||||
" last_updated TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO notification_tracker_state "
|
||||
"(id, notification_tracker_id, collection_id, collection_name, "
|
||||
"shared, asset_ids, pending_asset_ids, last_updated) "
|
||||
"SELECT id, tracker_id, collection_id, collection_name, "
|
||||
"shared, asset_ids, pending_asset_ids, last_updated "
|
||||
"FROM tracker_state"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE tracker_state"))
|
||||
logger.info("Renamed table tracker_state → notification_tracker_state (with column rename tracker_id → notification_tracker_id)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Add chat_action column to notification_target
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "notification_target"):
|
||||
if not await _has_column(conn, "notification_target", "chat_action"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE notification_target ADD COLUMN chat_action TEXT")
|
||||
)
|
||||
logger.info("Added chat_action column to notification_target table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Rename tracker_id → notification_tracker_id in event_log
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "event_log"):
|
||||
if await _has_column(conn, "event_log", "tracker_id") and not await _has_column(conn, "event_log", "notification_tracker_id"):
|
||||
# Recreate event_log with renamed column
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE event_log_new ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" tracker_name TEXT DEFAULT '',"
|
||||
" provider_id INTEGER,"
|
||||
" provider_name TEXT DEFAULT '',"
|
||||
" event_type TEXT,"
|
||||
" collection_id TEXT,"
|
||||
" collection_name TEXT,"
|
||||
" assets_count INTEGER DEFAULT 0,"
|
||||
" details TEXT,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO event_log_new "
|
||||
"(id, notification_tracker_id, tracker_name, provider_id, "
|
||||
"provider_name, event_type, collection_id, collection_name, "
|
||||
"assets_count, details, created_at) "
|
||||
"SELECT id, tracker_id, tracker_name, provider_id, "
|
||||
"provider_name, event_type, collection_id, collection_name, "
|
||||
"assets_count, details, created_at "
|
||||
"FROM event_log"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE event_log"))
|
||||
await conn.execute(text("ALTER TABLE event_log_new RENAME TO event_log"))
|
||||
logger.info("Renamed column tracker_id → notification_tracker_id in event_log")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Create command_config table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_config"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_config ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" user_id INTEGER NOT NULL REFERENCES user(id),"
|
||||
" provider_type TEXT NOT NULL,"
|
||||
" name TEXT NOT NULL,"
|
||||
" icon TEXT DEFAULT '',"
|
||||
" enabled_commands TEXT DEFAULT '[]',"
|
||||
" locale TEXT DEFAULT 'en',"
|
||||
" response_mode TEXT DEFAULT 'media',"
|
||||
" default_count INTEGER DEFAULT 5,"
|
||||
" rate_limits TEXT DEFAULT '{}',"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_config table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Create command_tracker table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_tracker"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_tracker ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" user_id INTEGER NOT NULL REFERENCES user(id),"
|
||||
" provider_id INTEGER NOT NULL REFERENCES service_provider(id),"
|
||||
" command_config_id INTEGER NOT NULL REFERENCES command_config(id),"
|
||||
" name TEXT NOT NULL,"
|
||||
" icon TEXT DEFAULT '',"
|
||||
" enabled INTEGER DEFAULT 1,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_tracker table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Create command_tracker_listener table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_tracker_listener"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_tracker_listener ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" command_tracker_id INTEGER NOT NULL REFERENCES command_tracker(id),"
|
||||
" listener_type TEXT NOT NULL,"
|
||||
" listener_id INTEGER NOT NULL,"
|
||||
" created_at TIMESTAMP,"
|
||||
" UNIQUE(command_tracker_id, listener_type, listener_id)"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_tracker_listener table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Migrate TelegramBot.commands_config → CommandConfig rows
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "telegram_bot") and await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
# Only migrate if command_config table is empty (idempotent)
|
||||
cc_count = (await conn.execute(text("SELECT COUNT(*) FROM command_config"))).scalar()
|
||||
if cc_count == 0:
|
||||
bots = (await conn.execute(text(
|
||||
"SELECT id, user_id, commands_config FROM telegram_bot"
|
||||
))).fetchall()
|
||||
migrated = 0
|
||||
for bot in bots:
|
||||
bot_id, user_id, raw_config = bot[0], bot[1], bot[2]
|
||||
if not raw_config:
|
||||
continue
|
||||
try:
|
||||
cfg = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
# Skip empty/default configs
|
||||
if not cfg or cfg == {}:
|
||||
continue
|
||||
|
||||
# Extract fields from legacy commands_config
|
||||
enabled_commands = json.dumps(cfg.get("enabled_commands", []))
|
||||
locale = cfg.get("locale", "en")
|
||||
response_mode = cfg.get("response_mode", "media")
|
||||
default_count = cfg.get("default_count", 5)
|
||||
rate_limits = json.dumps(cfg.get("rate_limits", {}))
|
||||
provider_type = cfg.get("provider_type", "immich")
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO command_config "
|
||||
"(user_id, provider_type, name, enabled_commands, locale, "
|
||||
"response_mode, default_count, rate_limits, created_at) "
|
||||
"VALUES (:uid, :pt, :name, :ec, :locale, :rm, :dc, :rl, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{
|
||||
"uid": user_id,
|
||||
"pt": provider_type,
|
||||
"name": f"Bot #{bot_id} Commands",
|
||||
"ec": enabled_commands,
|
||||
"locale": locale,
|
||||
"rm": response_mode,
|
||||
"dc": default_count,
|
||||
"rl": rate_limits,
|
||||
},
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
if migrated:
|
||||
logger.info("Migrated %d bot commands_config → command_config rows", migrated)
|
||||
|
||||
# NOTE: We intentionally do NOT drop commands_config from telegram_bot
|
||||
# or notification_tracker_target. SQLite doesn't support DROP COLUMN in
|
||||
# all versions, and SQLModel will simply ignore columns not defined on
|
||||
# the model class. The columns will remain in the DB but are unused.
|
||||
|
||||
Reference in New Issue
Block a user