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()
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user