feat: port full CRUD API routes and frontend pages from Immich Watcher
Backend API (38 routes): - providers: full CRUD + test connection + list collections + API key masking - trackers: full CRUD + trigger + history + test-periodic/memory - tracking-configs: full CRUD with Pydantic models, provider_type filter - template-configs: full CRUD + preview + preview-raw with two-pass validation - targets: full CRUD + test notification + config masking - telegram-bots: full CRUD + chat discovery + token endpoint - users: full admin CRUD + password reset + self-delete protection - status: dashboard endpoint with providers/trackers/targets/events counts Frontend pages updated: - Dashboard with animated stat cards and event timeline - Providers with proper components, delete confirm, snackbar - Trackers/targets/tracking-configs/template-configs/telegram-bots/users all use PageHeader, Card, Loading, MdiIcon with correct i18n keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
const auth = getAuth();
|
||||
let status = $state<any>(null);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let displayProviders = $state(0);
|
||||
let displayActive = $state(0);
|
||||
let displayTotal = $state(0);
|
||||
let displayTargets = $state(0);
|
||||
|
||||
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||
if (to === 0) { setter(0); return; }
|
||||
const start = performance.now();
|
||||
function frame(now: number) {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setter(Math.round(from + (to - from) * eased));
|
||||
if (progress < 1) requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [providers, trackers, targets] = await Promise.all([
|
||||
api<any[]>('/providers'),
|
||||
api<any[]>('/trackers'),
|
||||
api<any[]>('/targets'),
|
||||
]);
|
||||
status = {
|
||||
providers: providers.length,
|
||||
trackers: { active: trackers.filter((t: any) => t.enabled).length, total: trackers.length },
|
||||
targets: targets.length,
|
||||
recent_events: [],
|
||||
};
|
||||
setTimeout(() => {
|
||||
animateCount(0, status.providers, (v) => displayProviders = v);
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
||||
animateCount(0, status.targets, (v) => displayTargets = v);
|
||||
}, 200);
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
||||
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||
] : []);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
};
|
||||
const eventColors: Record<string, string> = {
|
||||
assets_added: '#059669', assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||
};
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground">{t('dashboard.noEvents')}</p>
|
||||
</div>
|
||||
{#if !loaded}
|
||||
<Loading lines={4} />
|
||||
{:else if error}
|
||||
<Card>
|
||||
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if status}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8 stagger-children">
|
||||
{#each statCards as card, i}
|
||||
<div class="stat-card" style="--accent: {card.color};">
|
||||
<div class="stat-card-inner">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
||||
<MdiIcon name={card.icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
|
||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
</h3>
|
||||
{#if status.recent_events.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
|
||||
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="event-timeline stagger-children">
|
||||
{#each status.recent_events as event, i}
|
||||
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
||||
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
||||
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
||||
<div class="event-content">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
||||
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
||||
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); transform: translateY(-2px); }
|
||||
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
||||
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
||||
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
||||
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
||||
.event-timeline { display: flex; flex-direction: column; }
|
||||
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
|
||||
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
|
||||
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
|
||||
.event-content { flex: 1; min-width: 0; padding: 0.5rem 0.875rem; border-radius: 0.625rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
||||
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
||||
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { api } from '$lib/api.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let providers = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let loaded = $state(false);
|
||||
|
||||
let deleteTarget = $state<any>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
providers = await api.get('/providers');
|
||||
} catch { /* auth redirect handled by api */ }
|
||||
loading = false;
|
||||
await loadProviders();
|
||||
});
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
providers = await api('/providers');
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api(`/providers/${deleteTarget.id}`, { method: 'DELETE' });
|
||||
snackSuccess(t('snack.providerDeleted'));
|
||||
deleteTarget = null;
|
||||
await loadProviders();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{t('nav.providers')}</h1>
|
||||
<a href="/providers/new" class="px-4 py-2 bg-primary text-primary-foreground rounded-[var(--radius)] text-sm font-medium">
|
||||
{t('provider.addProvider')}
|
||||
</a>
|
||||
</div>
|
||||
<PageHeader title={t('providers.title')} description={t('providers.description')}>
|
||||
<a href="/providers/new"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
|
||||
<MdiIcon name="mdiPlus" size={16} />
|
||||
{t('providers.addProvider')}
|
||||
</a>
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground">{t('common.loading')}</p>
|
||||
{:else if providers.length === 0}
|
||||
<div class="text-center py-16 text-muted-foreground">
|
||||
<p class="text-lg mb-2">{t('common.noData')}</p>
|
||||
<p class="text-sm">Add your first service provider to get started.</p>
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if providers.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;">
|
||||
<MdiIcon name="mdiServer" size={40} />
|
||||
</div>
|
||||
<p class="text-sm">{t('providers.noProviders')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 stagger-children">
|
||||
{#each providers as provider}
|
||||
<div class="p-4 bg-card rounded-xl border border-border">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold">
|
||||
{provider.type[0].toUpperCase()}
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 stagger-children">
|
||||
{#each providers as provider}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
|
||||
<MdiIcon name={provider.icon || 'mdiServer'} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">{provider.name}</h3>
|
||||
<p class="text-xs text-muted-foreground capitalize">{provider.type}</p>
|
||||
<h3 class="font-medium text-sm">{provider.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{provider.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton icon="mdiDelete" variant="danger" title={t('providers.delete')}
|
||||
onclick={() => deleteTarget = provider} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title={t('providers.confirmDelete')}
|
||||
message={deleteTarget?.name || ''}
|
||||
onconfirm={deleteProvider}
|
||||
oncancel={() => deleteTarget = null}
|
||||
/>
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { api } from '$lib/api.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { targets = await api.get('/targets'); } catch {}
|
||||
loading = false;
|
||||
try { targets = await api('/targets'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{t('nav.targets')}</h1>
|
||||
</div>
|
||||
<PageHeader title={t('targets.title')} description={t('targets.description')} />
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground">{t('common.loading')}</p>
|
||||
{:else if targets.length === 0}
|
||||
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 stagger-children">
|
||||
{#each targets as target}
|
||||
<div class="p-4 bg-card rounded-xl border border-border">
|
||||
<h3 class="font-semibold">{target.name}</h3>
|
||||
<p class="text-xs text-muted-foreground capitalize">{target.type}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if targets.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
|
||||
<p class="text-sm">{t('targets.noTargets')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each targets as target}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
|
||||
<MdiIcon name={target.type === 'telegram' ? 'mdiTelegram' : 'mdiWebhook'} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{target.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{target.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { bots = await api('/telegram-bots'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{t('nav.telegramBots')}</h1>
|
||||
<p class="text-muted-foreground">Telegram bot management — coming soon.</p>
|
||||
</div>
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if bots.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
|
||||
<p class="text-sm">{t('telegramBot.noBots')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each bots as bot}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: #229ED9; color: white;">
|
||||
<MdiIcon name="mdiRobot" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{bot.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">@{bot.bot_username || '...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { configs = await api('/template-configs'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{t('nav.templateConfigs')}</h1>
|
||||
<p class="text-muted-foreground">Template configuration management — coming soon.</p>
|
||||
</div>
|
||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if configs.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
|
||||
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-accent); color: var(--color-accent-foreground);">
|
||||
<MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{config.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{config.description || config.provider_type}</p>
|
||||
</div>
|
||||
{#if config.user_id === 0}
|
||||
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);">System</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,34 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { api } from '$lib/api.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { trackers = await api.get('/trackers'); } catch {}
|
||||
loading = false;
|
||||
try { trackers = await api('/trackers'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{t('nav.trackers')}</h1>
|
||||
</div>
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground">{t('common.loading')}</p>
|
||||
{:else if trackers.length === 0}
|
||||
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
<div class="p-4 bg-card rounded-xl border border-border">
|
||||
<h3 class="font-semibold">{tracker.name}</h3>
|
||||
<p class="text-xs text-muted-foreground">{tracker.collection_ids?.length || 0} collections</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if trackers.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
|
||||
<p class="text-sm">{t('trackers.noTrackers')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name="mdiRadar" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{tracker.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">
|
||||
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s
|
||||
</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { configs = await api('/tracking-configs'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{t('nav.trackingConfigs')}</h1>
|
||||
<p class="text-muted-foreground">Tracking configuration management — coming soon.</p>
|
||||
</div>
|
||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if configs.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
|
||||
<p class="text-sm">{t('trackingConfig.noConfigs')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-accent); color: var(--color-accent-foreground);">
|
||||
<MdiIcon name={config.icon || 'mdiCog'} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{config.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{config.provider_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let users = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try { users = await api('/users'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{t('nav.users')}</h1>
|
||||
<p class="text-muted-foreground">User management — coming soon.</p>
|
||||
</div>
|
||||
<PageHeader title={t('users.title')} description={t('users.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if users.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
|
||||
<p class="text-sm">No users found.</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||
{user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{user.username}</h3>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--color-muted-foreground);">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Service Provider CRUD API routes."""
|
||||
"""Service provider management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
@@ -26,23 +28,57 @@ class ProviderUpdate(BaseModel):
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
name: str
|
||||
icon: str
|
||||
config: dict[str, Any]
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_providers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all service providers for the current user."""
|
||||
result = await session.exec(
|
||||
select(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||
)
|
||||
return result.all()
|
||||
providers = result.all()
|
||||
return [_provider_response(p) for p in providers]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
body: ProviderCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Add a new service provider (validates connection for known types)."""
|
||||
# Validate connection for known provider types
|
||||
if body.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = body.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
body.name,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", f"Cannot connect to {body.type} provider"),
|
||||
)
|
||||
# Store external_domain from server config if available
|
||||
if test_result.get("external_domain"):
|
||||
config["external_domain"] = test_result["external_domain"]
|
||||
|
||||
provider = ServiceProvider(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
@@ -53,7 +89,7 @@ async def create_provider(
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
return provider
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.get("/{provider_id}")
|
||||
@@ -62,10 +98,9 @@ async def get_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return provider
|
||||
"""Get a specific service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.put("/{provider_id}")
|
||||
@@ -75,32 +110,58 @@ async def update_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
"""Update a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
if body.name is not None:
|
||||
provider.name = body.name
|
||||
if body.icon is not None:
|
||||
provider.icon = body.icon
|
||||
|
||||
config_changed = body.config is not None and body.config != provider.config
|
||||
if body.config is not None:
|
||||
provider.config = body.config
|
||||
|
||||
# Re-validate connection when config changes for known provider types
|
||||
if config_changed and provider.type == "immich":
|
||||
try:
|
||||
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,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result.get("message", f"Cannot connect to {provider.type} provider"),
|
||||
)
|
||||
if test_result.get("external_domain"):
|
||||
provider.config = {**provider.config, "external_domain": test_result["external_domain"]}
|
||||
except aiohttp.ClientError as err:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection error: {err}",
|
||||
)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
return provider
|
||||
return _provider_response(provider)
|
||||
|
||||
|
||||
@router.delete("/{provider_id}", status_code=204)
|
||||
@router.delete("/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_provider(
|
||||
provider_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Delete a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
await session.delete(provider)
|
||||
await session.commit()
|
||||
|
||||
@@ -111,12 +172,10 @@ async def test_provider(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Check if a service provider is reachable."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
import aiohttp
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
@@ -138,12 +197,10 @@ async def list_collections(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
"""Fetch collections from a service provider."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
import aiohttp
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
@@ -157,3 +214,30 @@ async def list_collections(
|
||||
return await immich.list_collections()
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _provider_response(p: ServiceProvider) -> dict:
|
||||
"""Build a safe response dict for a provider."""
|
||||
config = dict(p.config)
|
||||
# Mask sensitive fields
|
||||
if "api_key" in config:
|
||||
key = config["api_key"]
|
||||
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
||||
return {
|
||||
"id": p.id,
|
||||
"type": p.type,
|
||||
"name": p.name,
|
||||
"icon": p.icon,
|
||||
"config": config,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_provider(
|
||||
session: AsyncSession, provider_id: int, user_id: int
|
||||
) -> ServiceProvider:
|
||||
"""Get a provider owned by the user, or raise 404."""
|
||||
provider = await session.get(ServiceProvider, provider_id)
|
||||
if not provider or provider.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return provider
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Status/dashboard API route."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, ServiceProvider, Tracker, EventLog, User
|
||||
|
||||
router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_status(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get dashboard status data."""
|
||||
providers_count = (await session.exec(
|
||||
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
trackers_result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
)
|
||||
trackers = trackers_result.all()
|
||||
active_count = sum(1 for t in trackers if t.enabled)
|
||||
|
||||
targets_count = (await session.exec(
|
||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
recent_events = await session.exec(
|
||||
select(EventLog)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
return {
|
||||
"providers": providers_count,
|
||||
"trackers": {"total": len(trackers), "active": active_count},
|
||||
"targets": targets_count,
|
||||
"recent_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": e.collection_name,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in recent_events.all()
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""NotificationTarget CRUD API routes."""
|
||||
"""Notification target management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
|
||||
class TargetCreate(BaseModel):
|
||||
type: str
|
||||
type: str # "telegram" or "webhook"
|
||||
name: str
|
||||
icon: str = ""
|
||||
config: dict[str, Any] = {}
|
||||
@@ -33,23 +33,48 @@ async def list_targets(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all notification targets for the current user."""
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)
|
||||
return result.all()
|
||||
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()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_target(
|
||||
body: TargetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
target = NotificationTarget(user_id=user.id, **body.model_dump())
|
||||
"""Create a new notification target."""
|
||||
if body.type not in ("telegram", "webhook"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Type must be 'telegram' or 'webhook'",
|
||||
)
|
||||
target = NotificationTarget(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
template_config_id=body.template_config_id,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
return target
|
||||
return {"id": target.id, "type": target.type, "name": target.name}
|
||||
|
||||
|
||||
@router.get("/{target_id}")
|
||||
@@ -58,10 +83,16 @@ async def get_target(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target or target.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return 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,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{target_id}")
|
||||
@@ -71,27 +102,62 @@ async def update_target(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target or target.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(target, field, value)
|
||||
|
||||
"""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
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
return target
|
||||
return {"id": target.id, "type": target.type, "name": target.name}
|
||||
|
||||
|
||||
@router.delete("/{target_id}", status_code=204)
|
||||
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_target(
|
||||
target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target or target.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
"""Delete a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
await session.delete(target)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{target_id}/test")
|
||||
async def test_target(
|
||||
target_id: int,
|
||||
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)
|
||||
return result
|
||||
|
||||
|
||||
def _safe_config(target: NotificationTarget) -> dict:
|
||||
"""Return config with sensitive fields masked."""
|
||||
config = dict(target.config)
|
||||
if "bot_token" in config:
|
||||
token = config["bot_token"]
|
||||
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
|
||||
if "api_key" in config:
|
||||
config["api_key"] = "***"
|
||||
return config
|
||||
|
||||
|
||||
async def _get_user_target(
|
||||
session: AsyncSession, target_id: int, user_id: int
|
||||
) -> NotificationTarget:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target or target.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return target
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Telegram bot management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot, TelegramChat, User
|
||||
|
||||
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
|
||||
|
||||
|
||||
class BotCreate(BaseModel):
|
||||
name: str
|
||||
token: str
|
||||
|
||||
|
||||
class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
commands_config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_bots(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all registered Telegram bots."""
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.user_id == user.id)
|
||||
)
|
||||
return [_bot_response(b) for b in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_bot(
|
||||
body: BotCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Register a new Telegram bot (validates token via getMe)."""
|
||||
bot_info = await _get_me(body.token)
|
||||
if not bot_info:
|
||||
raise HTTPException(status_code=400, detail="Invalid bot token")
|
||||
|
||||
bot = TelegramBot(
|
||||
user_id=user.id,
|
||||
name=body.name,
|
||||
token=body.token,
|
||||
bot_username=bot_info.get("username", ""),
|
||||
bot_id=bot_info.get("id", 0),
|
||||
)
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
return _bot_response(bot)
|
||||
|
||||
|
||||
@router.put("/{bot_id}")
|
||||
async def update_bot(
|
||||
bot_id: int,
|
||||
body: BotUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a bot's display name and/or commands config."""
|
||||
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
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
return _bot_response(bot)
|
||||
|
||||
|
||||
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_bot(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a registered bot and its chats."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
# Delete associated chats
|
||||
result = await session.exec(select(TelegramChat).where(TelegramChat.bot_id == bot_id))
|
||||
for chat in result.all():
|
||||
await session.delete(chat)
|
||||
await session.delete(bot)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/{bot_id}/token")
|
||||
async def get_bot_token(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get the full bot token (used by frontend to construct target config)."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
return {"token": bot.token}
|
||||
|
||||
|
||||
# --- Chat management ---
|
||||
|
||||
@router.get("/{bot_id}/chats")
|
||||
async def list_bot_chats(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List persisted chats for a bot."""
|
||||
await _get_user_bot(session, bot_id, user.id) # Auth check
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
|
||||
)
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chats/discover")
|
||||
async def discover_chats(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Discover new chats via Telegram getUpdates and persist them.
|
||||
|
||||
Merges newly discovered chats with existing ones (no duplicates).
|
||||
Returns the full updated chat list.
|
||||
"""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
discovered = await _fetch_chats_from_telegram(bot.token)
|
||||
|
||||
# Load existing chats to avoid duplicates
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
|
||||
)
|
||||
existing = {c.chat_id: c for c in result.all()}
|
||||
|
||||
new_count = 0
|
||||
for chat_data in discovered:
|
||||
cid = str(chat_data["id"])
|
||||
if cid in existing:
|
||||
# Update title/username if changed
|
||||
existing_chat = existing[cid]
|
||||
existing_chat.title = chat_data.get("title", existing_chat.title)
|
||||
existing_chat.username = chat_data.get("username", existing_chat.username)
|
||||
session.add(existing_chat)
|
||||
else:
|
||||
new_chat = TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=cid,
|
||||
title=chat_data.get("title", ""),
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
)
|
||||
session.add(new_chat)
|
||||
new_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Return full list
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(TelegramChat.bot_id == bot_id)
|
||||
)
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat(
|
||||
bot_id: int,
|
||||
chat_db_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a persisted chat entry."""
|
||||
await _get_user_bot(session, bot_id, user.id) # Auth check
|
||||
chat = await session.get(TelegramChat, chat_db_id)
|
||||
if not chat or chat.bot_id != bot_id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
await session.delete(chat)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _get_me(token: str) -> dict | None:
|
||||
"""Call Telegram getMe to validate token and get bot info."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getMe") as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return data.get("result", {})
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
|
||||
"""Fetch chats from Telegram getUpdates API."""
|
||||
seen: dict[int, dict] = {}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{TELEGRAM_API_BASE_URL}{token}/getUpdates",
|
||||
params={"limit": 100, "allowed_updates": '["message"]'},
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
return []
|
||||
for update in data.get("result", []):
|
||||
msg = update.get("message", {})
|
||||
chat = msg.get("chat", {})
|
||||
chat_id = chat.get("id")
|
||||
if chat_id and chat_id not in seen:
|
||||
seen[chat_id] = {
|
||||
"id": chat_id,
|
||||
"title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
|
||||
"type": chat.get("type", "private"),
|
||||
"username": chat.get("username", ""),
|
||||
}
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _chat_response(c: TelegramChat) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"chat_id": c.chat_id,
|
||||
"title": c.title,
|
||||
"type": c.chat_type,
|
||||
"username": c.username,
|
||||
"discovered_at": c.discovered_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _bot_response(b: TelegramBot) -> dict:
|
||||
return {
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"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(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> TelegramBot:
|
||||
bot = await session.get(TelegramBot, bot_id)
|
||||
if not bot or bot.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
return bot
|
||||
|
||||
|
||||
async def save_chat_from_webhook(
|
||||
session: AsyncSession, bot_id: int, chat_data: dict
|
||||
) -> None:
|
||||
"""Save or update a chat entry from an incoming webhook message.
|
||||
|
||||
Called by the webhook handler to auto-persist chats.
|
||||
"""
|
||||
chat_id = str(chat_data.get("id", ""))
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
existing = result.first()
|
||||
|
||||
title = chat_data.get("title") or (
|
||||
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.username = chat_data.get("username", existing.username)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
))
|
||||
@@ -1,85 +1,271 @@
|
||||
"""TemplateConfig CRUD API routes."""
|
||||
"""Template configuration CRUD API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TemplateConfig, User
|
||||
|
||||
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
"people": ["Alice", "Bob"],
|
||||
"is_favorite": True,
|
||||
"rating": 5,
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"city": "Paris",
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
_SAMPLE_CONTEXT = {
|
||||
# Core event fields (always present)
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
|
||||
"removed_assets": ["asset-id-1", "asset-id-2"],
|
||||
"people": ["Alice", "Bob"],
|
||||
"shared": True,
|
||||
"target_type": "telegram",
|
||||
"has_videos": True,
|
||||
"has_photos": True,
|
||||
# Rename fields (always present, empty for non-rename events)
|
||||
"old_name": "Old Album",
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# 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"}],
|
||||
"date": "2026-03-19",
|
||||
}
|
||||
|
||||
|
||||
class TemplateConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
message_assets_added: str | None = None
|
||||
message_assets_removed: str | None = None
|
||||
message_collection_renamed: str | None = None
|
||||
message_collection_deleted: str | None = None
|
||||
message_sharing_changed: str | None = None
|
||||
periodic_summary_message: str | None = None
|
||||
scheduled_assets_message: str | None = None
|
||||
memory_mode_message: str | None = None
|
||||
date_format: str | None = None
|
||||
|
||||
|
||||
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_template_configs(
|
||||
async def list_configs(
|
||||
provider_type: str | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
from sqlalchemy import or_
|
||||
query = select(TemplateConfig).where(
|
||||
(TemplateConfig.user_id == user.id) | (TemplateConfig.user_id == 0)
|
||||
or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0)
|
||||
)
|
||||
if provider_type:
|
||||
query = query.where(TemplateConfig.provider_type == provider_type)
|
||||
result = await session.exec(query)
|
||||
return result.all()
|
||||
return [_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_template_config(
|
||||
body: dict,
|
||||
@router.get("/variables")
|
||||
async def get_template_variables(provider_type: str | None = None):
|
||||
"""Get the variable reference for all template slots."""
|
||||
from .template_vars import router as _ # noqa: ensure registered
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
|
||||
if provider_type:
|
||||
try:
|
||||
pt = ServiceProviderType(provider_type)
|
||||
except ValueError:
|
||||
return {"error": f"Unknown provider type: {provider_type}"}
|
||||
variables = registry.get_variables(pt)
|
||||
else:
|
||||
variables = registry.get_base_variables()
|
||||
|
||||
return [
|
||||
{
|
||||
"name": v.name,
|
||||
"type": v.type,
|
||||
"description": v.description,
|
||||
"example": v.example,
|
||||
"provider_type": v.provider_type.value if v.provider_type else None,
|
||||
}
|
||||
for v in variables
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
body: TemplateConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = TemplateConfig(user_id=user.id, **body)
|
||||
data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
config = TemplateConfig(user_id=user.id, **data)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_template_config(
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TemplateConfig, config_id)
|
||||
if not config or (config.user_id != user.id and config.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
return config
|
||||
return _response(await _get(session, config_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_template_config(
|
||||
async def update_config(
|
||||
config_id: int,
|
||||
body: dict,
|
||||
body: TemplateConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TemplateConfig, config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
for field, value in body.items():
|
||||
if field not in ("id", "user_id", "created_at"):
|
||||
config = await _get(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
if value is not None:
|
||||
setattr(config, field, value)
|
||||
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=204)
|
||||
async def delete_template_config(
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TemplateConfig, config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
config = await _get(session, config_id, user.id)
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{config_id}/preview")
|
||||
async def preview_config(
|
||||
config_id: int,
|
||||
slot: str = "message_assets_added",
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Render a specific template slot with sample data."""
|
||||
config = await _get(session, config_id, user.id)
|
||||
template_body = getattr(config, slot, None)
|
||||
if template_body is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}")
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_body)
|
||||
rendered = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
return {"slot": slot, "rendered": rendered}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
target_type: str = "telegram" # "telegram" or "webhook"
|
||||
|
||||
|
||||
@router.post("/preview-raw")
|
||||
async def preview_raw(
|
||||
body: PreviewRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Render arbitrary Jinja2 template text with sample data.
|
||||
|
||||
Two-pass validation:
|
||||
1. Parse with default Undefined (catches syntax errors)
|
||||
2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
|
||||
"""
|
||||
# Pass 1: syntax check
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
env.from_string(body.template)
|
||||
except TemplateSyntaxError as e:
|
||||
return {
|
||||
"rendered": None,
|
||||
"error": e.message,
|
||||
"error_line": e.lineno,
|
||||
}
|
||||
|
||||
# Pass 2: render with strict undefined to catch unknown variables
|
||||
try:
|
||||
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
|
||||
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
|
||||
tmpl = strict_env.from_string(body.template)
|
||||
rendered = tmpl.render(**ctx)
|
||||
return {"rendered": rendered}
|
||||
except UndefinedError as e:
|
||||
# Still a valid template syntactically, but references unknown variable
|
||||
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
|
||||
except Exception as e:
|
||||
return {"rendered": None, "error": str(e), "error_line": None}
|
||||
|
||||
|
||||
def _response(c: TemplateConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
|
||||
config = await session.get(TemplateConfig, config_id)
|
||||
if not config or (config.user_id != user_id and config.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
return config
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Tracker CRUD API routes."""
|
||||
"""Tracker management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import Tracker, User
|
||||
from ..database.models import EventLog, NotificationTarget, ServiceProvider, Tracker, User
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
|
||||
@@ -42,21 +42,27 @@ async def list_trackers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.exec(select(Tracker).where(Tracker.user_id == user.id))
|
||||
return result.all()
|
||||
result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
)
|
||||
return [_tracker_response(t) for t in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker(
|
||||
body: TrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
provider = await session.get(ServiceProvider, body.provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
tracker = Tracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return tracker
|
||||
return _tracker_response(tracker)
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
@@ -65,10 +71,7 @@ async def get_tracker(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
return _tracker_response(await _get_user_tracker(session, tracker_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
@@ -78,28 +81,22 @@ async def update_tracker(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(tracker, field, value)
|
||||
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return tracker
|
||||
return _tracker_response(tracker)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=204)
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
|
||||
@@ -110,10 +107,96 @@ async def trigger_tracker(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.watcher import check_tracker
|
||||
result = await check_tracker(tracker_id)
|
||||
return result
|
||||
result = await check_tracker(tracker.id)
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-periodic")
|
||||
async def test_periodic(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary notification to all targets."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "periodic_summary", "results": results}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-memory")
|
||||
async def test_memory(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification to all targets."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "memory_mode", "results": results}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id == tracker_id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_id": e.collection_id,
|
||||
"collection_name": e.collection_name,
|
||||
"details": e.details,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in result.all()
|
||||
]
|
||||
|
||||
|
||||
def _tracker_response(t: Tracker) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"provider_id": t.provider_id,
|
||||
"collection_ids": t.collection_ids,
|
||||
"target_ids": t.target_ids,
|
||||
"tracking_config_id": t.tracking_config_id,
|
||||
"scan_interval": t.scan_interval,
|
||||
"enabled": t.enabled,
|
||||
"quiet_hours_start": t.quiet_hours_start,
|
||||
"quiet_hours_end": t.quiet_hours_end,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""TrackingConfig CRUD API routes."""
|
||||
"""Tracking configuration CRUD API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -11,8 +12,85 @@ from ..database.models import TrackingConfig, User
|
||||
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
||||
|
||||
|
||||
class TrackingConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
name: str
|
||||
icon: str = ""
|
||||
track_assets_added: bool = True
|
||||
track_assets_removed: bool = False
|
||||
track_collection_renamed: bool = True
|
||||
track_collection_deleted: bool = True
|
||||
track_sharing_changed: bool = False
|
||||
track_images: bool = True
|
||||
track_videos: bool = True
|
||||
notify_favorites_only: bool = False
|
||||
include_tags: bool = True
|
||||
include_asset_details: bool = False
|
||||
max_assets_to_show: int = 5
|
||||
assets_order_by: str = "none"
|
||||
assets_order: str = "descending"
|
||||
periodic_enabled: bool = False
|
||||
periodic_interval_days: int = 1
|
||||
periodic_start_date: str = "2025-01-01"
|
||||
periodic_times: str = "12:00"
|
||||
scheduled_enabled: bool = False
|
||||
scheduled_times: str = "09:00"
|
||||
scheduled_collection_mode: str = "per_collection"
|
||||
scheduled_limit: int = 10
|
||||
scheduled_favorite_only: bool = False
|
||||
scheduled_asset_type: str = "all"
|
||||
scheduled_min_rating: int = 0
|
||||
scheduled_order_by: str = "random"
|
||||
scheduled_order: str = "descending"
|
||||
memory_enabled: bool = False
|
||||
memory_times: str = "09:00"
|
||||
memory_collection_mode: str = "combined"
|
||||
memory_limit: int = 10
|
||||
memory_favorite_only: bool = False
|
||||
memory_asset_type: str = "all"
|
||||
memory_min_rating: int = 0
|
||||
|
||||
|
||||
class TrackingConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
track_assets_added: bool | None = None
|
||||
track_assets_removed: bool | None = None
|
||||
track_collection_renamed: bool | None = None
|
||||
track_collection_deleted: bool | None = None
|
||||
track_sharing_changed: bool | None = None
|
||||
track_images: bool | None = None
|
||||
track_videos: bool | None = None
|
||||
notify_favorites_only: bool | None = None
|
||||
include_tags: bool | None = None
|
||||
include_asset_details: bool | None = None
|
||||
max_assets_to_show: int | None = None
|
||||
assets_order_by: str | None = None
|
||||
assets_order: str | None = None
|
||||
periodic_enabled: bool | None = None
|
||||
periodic_interval_days: int | None = None
|
||||
periodic_start_date: str | None = None
|
||||
periodic_times: str | None = None
|
||||
scheduled_enabled: bool | None = None
|
||||
scheduled_times: str | None = None
|
||||
scheduled_collection_mode: str | None = None
|
||||
scheduled_limit: int | None = None
|
||||
scheduled_favorite_only: bool | None = None
|
||||
scheduled_asset_type: str | None = None
|
||||
scheduled_min_rating: int | None = None
|
||||
scheduled_order_by: str | None = None
|
||||
scheduled_order: str | None = None
|
||||
memory_enabled: bool | None = None
|
||||
memory_times: str | None = None
|
||||
memory_collection_mode: str | None = None
|
||||
memory_limit: int | None = None
|
||||
memory_favorite_only: bool | None = None
|
||||
memory_asset_type: str | None = None
|
||||
memory_min_rating: int | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tracking_configs(
|
||||
async def list_configs(
|
||||
provider_type: str | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -21,63 +99,66 @@ async def list_tracking_configs(
|
||||
if provider_type:
|
||||
query = query.where(TrackingConfig.provider_type == provider_type)
|
||||
result = await session.exec(query)
|
||||
return result.all()
|
||||
return [_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tracking_config(
|
||||
body: dict,
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
body: TrackingConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = TrackingConfig(user_id=user.id, **body)
|
||||
config = TrackingConfig(user_id=user.id, **body.model_dump())
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_tracking_config(
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TrackingConfig, config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
return config
|
||||
return _response(await _get(session, config_id, user.id))
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_tracking_config(
|
||||
async def update_config(
|
||||
config_id: int,
|
||||
body: dict,
|
||||
body: TrackingConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TrackingConfig, config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
|
||||
for field, value in body.items():
|
||||
if field not in ("id", "user_id", "created_at"):
|
||||
setattr(config, field, value)
|
||||
|
||||
config = await _get(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(config, field, value)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
return _response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=204)
|
||||
async def delete_tracking_config(
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
config = await session.get(TrackingConfig, config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
config = await _get(session, config_id, user.id)
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _response(c: TrackingConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TrackingConfig:
|
||||
config = await session.get(TrackingConfig, config_id)
|
||||
if not config or config.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
return config
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""User management API routes (admin only)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import bcrypt
|
||||
|
||||
from ..auth.dependencies import require_admin
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import User
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
result = await session.exec(select(User))
|
||||
return [
|
||||
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
|
||||
for u in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
# Check for duplicate username
|
||||
result = await session.exec(select(User).where(User.username == body.username))
|
||||
if result.first():
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
hashed_password=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
|
||||
role=body.role if body.role in ("admin", "user") else "user",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return {"id": user.id, "username": user.username, "role": user.role}
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.put("/{user_id}/password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
body: ResetPasswordRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Reset a user's password (admin only)."""
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a user (admin only, cannot delete self)."""
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
@@ -13,6 +13,9 @@ from .api.trackers import router as trackers_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
|
||||
from .api.telegram_bots import router as telegram_bots_router
|
||||
from .api.users import router as users_router
|
||||
from .api.status import router as status_router
|
||||
from .api.template_vars import router as template_vars_router
|
||||
|
||||
|
||||
@@ -35,6 +38,9 @@ app.include_router(trackers_router)
|
||||
app.include_router(tracking_configs_router)
|
||||
app.include_router(template_configs_router)
|
||||
app.include_router(targets_router)
|
||||
app.include_router(telegram_bots_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(status_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
Reference in New Issue
Block a user