feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
@@ -0,0 +1,252 @@
"""Data migrations for schema changes.
Handles converting legacy JSON-array relationships to proper junction tables.
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
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
# 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")
# 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)
# 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 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")
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
Also migrates:
- Tracker.tracking_config_id → TrackerTarget.tracking_config_id
- Tracker.quiet_hours_* → TrackerTarget.quiet_hours_*
- NotificationTarget.template_config_id → TrackerTarget.template_config_id
- TelegramBot.commands_config → TrackerTarget.commands_config (for telegram targets)
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:
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
# Load legacy data
trackers = (
await conn.execute(
text(
"SELECT id, target_ids, tracking_config_id, "
"quiet_hours_start, quiet_hours_end FROM tracker"
)
)
).fetchall()
if not trackers:
logger.debug("No trackers to migrate")
return
# 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:
targets = (
await conn.execute(
text(
"SELECT id, template_config_id FROM notification_target"
)
)
).fetchall()
for t in targets:
target_template_map[t[0]] = t[1]
# 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:
bots = (
await conn.execute(
text("SELECT id, commands_config FROM telegram_bot")
)
).fetchall()
for b in bots:
bot_commands_map[b[0]] = b[1]
# 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'")
)
).fetchall()
for tgt in tgt_rows:
try:
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 = (
await conn.execute(
text("SELECT id 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
except Exception:
pass
# Create TrackerTarget rows
import json
migrated = 0
for tracker in trackers:
tracker_id = tracker[0]
raw_target_ids = tracker[1]
tracking_config_id = tracker[2]
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)
except (json.JSONDecodeError, TypeError):
target_ids = []
elif isinstance(raw_target_ids, list):
target_ids = raw_target_ids
else:
target_ids = []
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)
)
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)"
),
{
"tid": tracker_id,
"tgtid": target_id,
"tcid": tracking_config_id,
"tmplid": template_config_id,
"qhs": quiet_hours_start,
"qhe": quiet_hours_end,
"cmd": commands_config,
},
)
migrated += 1
logger.info("Migrated %d tracker-target links", migrated)
@@ -141,6 +141,7 @@ class TemplateConfig(SQLModel, table=True):
memory_mode_message: str = Field(default="")
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
created_at: datetime = Field(default_factory=_utcnow)
@@ -221,8 +222,12 @@ class EventLog(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
tracker_name: str = Field(default="")
provider_id: int | None = Field(default=None)
provider_name: str = Field(default="")
event_type: str
collection_id: str
collection_name: str
assets_count: int = Field(default=0)
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)