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:
@@ -216,6 +216,73 @@ async def list_collections(
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def get_album_shared_links(
|
||||
provider_id: int,
|
||||
album_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Check shared links for a specific album."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
links = await immich.client.get_shared_links(album_id)
|
||||
return [
|
||||
{
|
||||
"id": link.id,
|
||||
"key": link.key,
|
||||
"has_password": link.has_password,
|
||||
"is_expired": link.is_expired,
|
||||
"is_accessible": link.is_accessible,
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def create_album_shared_link(
|
||||
provider_id: int,
|
||||
album_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Auto-create a public shared link for an album."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
success = await immich.client.create_shared_link(album_id)
|
||||
if success:
|
||||
return {"success": True}
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Failed to create shared link")
|
||||
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Provider type does not support shared links")
|
||||
|
||||
|
||||
def _provider_response(p: ServiceProvider) -> dict:
|
||||
"""Build a safe response dict for a provider."""
|
||||
config = dict(p.config)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Status/dashboard API route."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
async def get_status(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
# Event filtering
|
||||
event_type: str | None = Query(None),
|
||||
provider_id: int | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
sort: str = Query("newest"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Get dashboard status data."""
|
||||
"""Get dashboard status data with enriched events."""
|
||||
providers_count = (await session.exec(
|
||||
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||
)).one()
|
||||
@@ -31,24 +38,53 @@ async def get_status(
|
||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
recent_events = await session.exec(
|
||||
# Build events query with filters
|
||||
events_query = (
|
||||
select(EventLog)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
if event_type:
|
||||
events_query = events_query.where(EventLog.event_type == event_type)
|
||||
if provider_id is not None:
|
||||
events_query = events_query.where(EventLog.provider_id == provider_id)
|
||||
if search:
|
||||
events_query = events_query.where(
|
||||
EventLog.collection_name.contains(search)
|
||||
| EventLog.tracker_name.contains(search)
|
||||
| EventLog.provider_name.contains(search)
|
||||
)
|
||||
|
||||
# Count total matching events (for pagination)
|
||||
count_query = select(func.count()).select_from(events_query.subquery())
|
||||
total_events = (await session.exec(count_query)).one()
|
||||
|
||||
# Sort
|
||||
if sort == "oldest":
|
||||
events_query = events_query.order_by(EventLog.created_at.asc())
|
||||
else:
|
||||
events_query = events_query.order_by(EventLog.created_at.desc())
|
||||
|
||||
events_query = events_query.offset(offset).limit(limit)
|
||||
recent_events = await session.exec(events_query)
|
||||
|
||||
return {
|
||||
"providers": providers_count,
|
||||
"trackers": {"total": len(trackers), "active": active_count},
|
||||
"targets": targets_count,
|
||||
"total_events": total_events,
|
||||
"recent_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": e.collection_name,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"tracker_name": e.tracker_name or "",
|
||||
"provider_name": e.provider_name or "",
|
||||
"provider_id": e.provider_id,
|
||||
"assets_count": e.assets_count or 0,
|
||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||
"details": e.details or {},
|
||||
}
|
||||
for e in recent_events.all()
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Notification target management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, User
|
||||
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = ""
|
||||
config: dict[str, Any] = {}
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -37,18 +35,26 @@ async def list_targets(
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"type": t.type,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"config": _safe_config(t),
|
||||
"template_config_id": t.template_config_id,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
for t in result.all()
|
||||
]
|
||||
targets = result.all()
|
||||
|
||||
# Resolve chat names for telegram targets
|
||||
chat_names: dict[str, str] = {}
|
||||
for tgt in targets:
|
||||
if tgt.type == "telegram" and tgt.config.get("chat_id"):
|
||||
bot_id = tgt.config.get("bot_id")
|
||||
chat_id = str(tgt.config["chat_id"])
|
||||
if bot_id:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
chat = chat_result.first()
|
||||
if chat:
|
||||
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
|
||||
|
||||
return [_target_response(t, chat_names) for t in targets]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -69,7 +75,6 @@ async def create_target(
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
template_config_id=body.template_config_id,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
@@ -85,14 +90,7 @@ async def get_target(
|
||||
):
|
||||
"""Get a specific notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
return {
|
||||
"id": target.id,
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"template_config_id": target.template_config_id,
|
||||
}
|
||||
return _target_response(target)
|
||||
|
||||
|
||||
@router.put("/{target_id}")
|
||||
@@ -104,14 +102,8 @@ async def update_target(
|
||||
):
|
||||
"""Update a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
if body.name is not None:
|
||||
target.name = body.name
|
||||
if body.icon is not None:
|
||||
target.icon = body.icon
|
||||
if body.config is not None:
|
||||
target.config = body.config
|
||||
if body.template_config_id is not None:
|
||||
target.template_config_id = body.template_config_id
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(target, field, value)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
@@ -124,8 +116,14 @@ async def delete_target(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a notification target."""
|
||||
"""Delete a notification target and its tracker links."""
|
||||
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)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
await session.delete(target)
|
||||
await session.commit()
|
||||
|
||||
@@ -133,16 +131,36 @@ async def delete_target(
|
||||
@router.post("/{target_id}/test")
|
||||
async def test_target(
|
||||
target_id: int,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test notification to a target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await send_test_notification(target)
|
||||
result = await send_test_notification(target, locale=locale)
|
||||
return result
|
||||
|
||||
|
||||
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
|
||||
resp = {
|
||||
"id": target.id,
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"created_at": target.created_at.isoformat(),
|
||||
}
|
||||
# Attach resolved chat name for telegram targets
|
||||
if target.type == "telegram" and chat_names:
|
||||
bot_id = target.config.get("bot_id")
|
||||
chat_id = str(target.config.get("chat_id", ""))
|
||||
key = f"{bot_id}_{chat_id}"
|
||||
if key in chat_names:
|
||||
resp["chat_name"] = chat_names[key]
|
||||
return resp
|
||||
|
||||
|
||||
def _safe_config(target: NotificationTarget) -> dict:
|
||||
"""Return config with sensitive fields masked."""
|
||||
config = dict(target.config)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Telegram bot management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
|
||||
|
||||
class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
commands_config: dict | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -69,12 +69,12 @@ async def update_bot(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a bot's display name and/or commands config."""
|
||||
"""Update a bot's display name and icon."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
if body.name is not None:
|
||||
bot.name = body.name
|
||||
if body.commands_config is not None:
|
||||
bot.commands_config = body.commands_config
|
||||
if body.icon is not None:
|
||||
bot.icon = body.icon
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
@@ -173,6 +173,37 @@ async def discover_chats(
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chats/{chat_id}/test")
|
||||
async def test_chat(
|
||||
bot_id: int,
|
||||
chat_id: str,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test message to a chat via the bot."""
|
||||
from ..services.notifier import _get_test_message
|
||||
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
message = _get_test_message(locale, "telegram")
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return {"success": True}
|
||||
return {"success": False, "error": data.get("description", "Unknown error")}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat(
|
||||
bot_id: int,
|
||||
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
return {
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"bot_username": b.bot_username,
|
||||
"bot_id": b.bot_id,
|
||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||
"commands_config": b.commands_config,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# Public share URLs (may be empty if no shared link exists)
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"protected_url": "",
|
||||
"album_url": "https://immich.example.com/albums/b2eeeaa4",
|
||||
# Common date/location (set when all assets share the same value)
|
||||
"common_date": "19.03.2026",
|
||||
"common_location": "Paris, France",
|
||||
# Date format strings (from template config)
|
||||
"date_format": "%d.%m.%Y, %H:%M UTC",
|
||||
"date_only_format": "%d.%m.%Y",
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
"owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +123,7 @@ class TemplateConfigCreate(BaseModel):
|
||||
scheduled_assets_message: str | None = None
|
||||
memory_mode_message: str | None = None
|
||||
date_format: str | None = None
|
||||
date_only_format: str | None = None
|
||||
|
||||
|
||||
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
||||
@@ -142,10 +160,15 @@ async def get_template_variables():
|
||||
"collection_id": "Collection ID (UUID)",
|
||||
"collection_name": "Collection name",
|
||||
"collection_url": "Public share URL (empty if not shared)",
|
||||
"public_url": "Public share link URL (empty if no link exists)",
|
||||
"protected_url": "Password-protected share link URL (empty if none)",
|
||||
"added_count": "Number of assets added",
|
||||
"removed_count": "Number of assets removed",
|
||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||
"shared": "Whether collection is shared (boolean)",
|
||||
"photo_count": "Total photo count in album",
|
||||
"video_count": "Total video count in album",
|
||||
"owner": "Album owner name",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
"has_videos": "Whether added assets contain videos (boolean)",
|
||||
"has_photos": "Whether added assets contain photos (boolean)",
|
||||
@@ -177,6 +200,7 @@ async def get_template_variables():
|
||||
"city": "City name",
|
||||
"state": "State/region name",
|
||||
"country": "Country name",
|
||||
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||
"url": "Public viewer URL (if shared)",
|
||||
"download_url": "Direct download URL (if shared)",
|
||||
"photo_url": "Preview image URL (images only, if shared)",
|
||||
@@ -185,6 +209,7 @@ async def get_template_variables():
|
||||
album_fields = {
|
||||
"name": "Collection/album name",
|
||||
"url": "Share URL",
|
||||
"public_url": "Public share link URL",
|
||||
"asset_count": "Total assets in collection",
|
||||
"shared": "Whether collection is shared",
|
||||
}
|
||||
@@ -196,7 +221,12 @@ async def get_template_variables():
|
||||
return {
|
||||
"message_assets_added": {
|
||||
"description": "Notification when new assets are added to a collection",
|
||||
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
|
||||
"variables": {
|
||||
**event_vars,
|
||||
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
|
||||
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
|
||||
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
|
||||
},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
"message_assets_removed": {
|
||||
@@ -308,6 +338,8 @@ async def preview_config(
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
target_type: str = "telegram" # "telegram" or "webhook"
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
|
||||
|
||||
@router.post("/preview-raw")
|
||||
@@ -334,7 +366,14 @@ async def preview_raw(
|
||||
|
||||
# Pass 2: render with strict undefined to catch unknown variables
|
||||
try:
|
||||
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
|
||||
from datetime import datetime
|
||||
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
|
||||
"date_format": body.date_format, "date_only_format": body.date_only_format}
|
||||
# Format common_date using the provided date_only_format
|
||||
try:
|
||||
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
|
||||
except (ValueError, TypeError):
|
||||
ctx["common_date"] = "19.03.2026"
|
||||
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
|
||||
tmpl = strict_env.from_string(body.template)
|
||||
rendered = tmpl.render(**ctx)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -152,6 +152,7 @@ async def delete_tracker_target(
|
||||
async def test_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -166,7 +167,7 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
from ..services.notifier import send_test_notification
|
||||
r = await send_test_notification(target)
|
||||
r = await send_test_notification(target, locale=locale)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ async def tracker_history(
|
||||
"collection_id": e.collection_id,
|
||||
"collection_name": e.collection_name,
|
||||
"details": e.details,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||
}
|
||||
for e in result.all()
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"""Notify Bridge Server — FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Ensure app-level loggers are visible
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("notify_bridge_server").setLevel(logging.DEBUG)
|
||||
logging.getLogger("notify_bridge_core").setLevel(logging.DEBUG)
|
||||
|
||||
from .database.engine import init_db
|
||||
from .database.models import * # noqa: F401,F403 — ensure all models registered
|
||||
|
||||
from .auth.routes import router as auth_router
|
||||
from .api.providers import router as providers_router
|
||||
from .api.trackers import router as trackers_router
|
||||
from .api.tracker_targets import router as tracker_targets_router
|
||||
from .api.tracking_configs import router as tracking_configs_router
|
||||
from .api.template_configs import router as template_configs_router
|
||||
from .api.targets import router as targets_router
|
||||
@@ -22,6 +29,12 @@ from .api.template_vars import router as template_vars_router
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
# Run data migrations (idempotent)
|
||||
from .database.engine import get_engine
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets
|
||||
engine = get_engine()
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(engine)
|
||||
await _seed_default_templates()
|
||||
from .services.scheduler import start_scheduler
|
||||
await start_scheduler()
|
||||
@@ -35,6 +48,7 @@ app.include_router(auth_router)
|
||||
app.include_router(template_vars_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(trackers_router)
|
||||
app.include_router(tracker_targets_router)
|
||||
app.include_router(tracking_configs_router)
|
||||
app.include_router(template_configs_router)
|
||||
app.include_router(targets_router)
|
||||
@@ -49,7 +63,7 @@ async def health():
|
||||
|
||||
|
||||
async def _seed_default_templates():
|
||||
"""Seed default templates on first startup if no templates exist."""
|
||||
"""Seed default templates on first startup if none exist."""
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from .database.engine import get_engine
|
||||
|
||||
@@ -8,21 +8,37 @@ from ..database.models import NotificationTarget
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_TEST_MESSAGES: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
|
||||
"webhook": "Test notification from Notify Bridge",
|
||||
},
|
||||
"ru": {
|
||||
"telegram": "\u2705 Тестовое сообщение от <b>Notify Bridge</b>",
|
||||
"webhook": "Тестовое уведомление от Notify Bridge",
|
||||
},
|
||||
}
|
||||
|
||||
async def send_test_notification(target: NotificationTarget) -> dict:
|
||||
|
||||
def _get_test_message(locale: str, target_type: str) -> str:
|
||||
msgs = _TEST_MESSAGES.get(locale, _TEST_MESSAGES["en"])
|
||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target)
|
||||
return await _test_telegram(target, locale)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target)
|
||||
return await _test_webhook(target, locale)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram(target: NotificationTarget) -> dict:
|
||||
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
@@ -34,7 +50,7 @@ async def _test_telegram(target: NotificationTarget) -> dict:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
chat_id=str(chat_id),
|
||||
caption="Test notification from Notify Bridge",
|
||||
caption=_get_test_message(locale, "telegram"),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +104,7 @@ async def _test_webhook_with_message(target: NotificationTarget, message: str) -
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
@@ -99,6 +115,6 @@ async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": "Test notification from Notify Bridge",
|
||||
"message": _get_test_message(locale, "webhook"),
|
||||
"event_type": "test",
|
||||
})
|
||||
|
||||
@@ -174,11 +174,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
session.add(new_ts)
|
||||
|
||||
for event in events:
|
||||
assets_count = event.added_count or event.removed_count or 0
|
||||
log = EventLog(
|
||||
tracker_id=tracker_id,
|
||||
tracker_name=tracker.name,
|
||||
provider_id=provider.id,
|
||||
provider_name=provider_name,
|
||||
event_type=event.event_type.value,
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=assets_count,
|
||||
details={
|
||||
"added_count": event.added_count,
|
||||
"removed_count": event.removed_count,
|
||||
@@ -233,8 +238,11 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=slots,
|
||||
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("external_domain", ""),
|
||||
))
|
||||
|
||||
if target_configs:
|
||||
|
||||
Reference in New Issue
Block a user