- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide blockierenden Bibliotheken laufen in einem Thread - Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen, überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat - APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich abgelaufene Sitzungen auf - Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener Versand wird beim nächsten Lauf erneut versucht - Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf - Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll - 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
"""Benachrichtigungsregeln, Versandprotokoll und Testversand."""
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Query, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.api.deps import DbSession
|
|
from app.core.config import settings
|
|
from app.core.errors import ConflictError
|
|
from app.models import NotificationLog, NotificationRule
|
|
from app.models.enums import NotificationChannel, NotificationStatus
|
|
from app.scheduler import next_run_time
|
|
from app.schemas.common import ErrorResponse, MessageResponse
|
|
from app.schemas.notification import (
|
|
ChannelStatusOut,
|
|
NotificationLogOut,
|
|
NotificationRuleCreate,
|
|
NotificationRuleOut,
|
|
NotificationRuleUpdate,
|
|
NotificationSettingsOut,
|
|
RunResponse,
|
|
TestResultOut,
|
|
TestSendRequest,
|
|
TestSendResponse,
|
|
)
|
|
from app.services.channels import get_channel
|
|
from app.services.crud import apply_updates, get_or_404
|
|
from app.services.notifications import run_all, send_test
|
|
|
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
|
|
|
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
|
|
|
CHANNEL_HINTS = {
|
|
NotificationChannel.SMTP: "SMTP_HOST und SMTP_FROM setzen.",
|
|
NotificationChannel.APPRISE: "APPRISE_URLS setzen, komma-separiert.",
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"/settings",
|
|
response_model=NotificationSettingsOut,
|
|
summary="Einrichtungsstand",
|
|
description="Zeigt, welche Kanäle einsatzbereit sind und wann der nächste Lauf ansteht.",
|
|
)
|
|
async def read_settings() -> NotificationSettingsOut:
|
|
kanaele = []
|
|
for art, hinweis in CHANNEL_HINTS.items():
|
|
eingerichtet = get_channel(art).is_configured()
|
|
kanaele.append(
|
|
ChannelStatusOut(
|
|
channel=art,
|
|
configured=eingerichtet,
|
|
detail="Einsatzbereit." if eingerichtet else hinweis,
|
|
)
|
|
)
|
|
|
|
return NotificationSettingsOut(
|
|
enabled=settings.notifications_enabled,
|
|
scheduler_enabled=settings.scheduler_enabled,
|
|
run_at=f"{settings.notification_hour:02d}:{settings.notification_minute:02d}",
|
|
timezone=settings.timezone,
|
|
next_run_at=next_run_time(),
|
|
channels=kanaele,
|
|
)
|
|
|
|
|
|
@router.get("/rules", response_model=list[NotificationRuleOut], summary="Regeln auflisten")
|
|
async def list_rules(session: DbSession) -> list[NotificationRule]:
|
|
stmt = select(NotificationRule).order_by(NotificationRule.type, NotificationRule.channel)
|
|
return list((await session.execute(stmt)).scalars().all())
|
|
|
|
|
|
@router.post(
|
|
"/rules",
|
|
response_model=NotificationRuleOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Regel anlegen",
|
|
)
|
|
async def create_rule(payload: NotificationRuleCreate, session: DbSession) -> NotificationRule:
|
|
regel = NotificationRule(**payload.model_dump())
|
|
session.add(regel)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError as exc:
|
|
await session.rollback()
|
|
raise ConflictError("Diese Regel existiert bereits.") from exc
|
|
await session.refresh(regel)
|
|
return regel
|
|
|
|
|
|
@router.patch(
|
|
"/rules/{rule_id}",
|
|
response_model=NotificationRuleOut,
|
|
responses=NOT_FOUND,
|
|
summary="Regel ändern",
|
|
)
|
|
async def update_rule(
|
|
rule_id: int, payload: NotificationRuleUpdate, session: DbSession
|
|
) -> NotificationRule:
|
|
regel = await get_or_404(session, NotificationRule, rule_id)
|
|
apply_updates(regel, payload)
|
|
await session.commit()
|
|
await session.refresh(regel)
|
|
return regel
|
|
|
|
|
|
@router.delete(
|
|
"/rules/{rule_id}",
|
|
response_model=MessageResponse,
|
|
responses=NOT_FOUND,
|
|
summary="Regel löschen",
|
|
description="Entfernt die Regel samt ihrem Versandprotokoll.",
|
|
)
|
|
async def delete_rule(rule_id: int, session: DbSession) -> MessageResponse:
|
|
regel = await get_or_404(session, NotificationRule, rule_id)
|
|
await session.delete(regel)
|
|
await session.commit()
|
|
return MessageResponse(detail="Regel gelöscht.")
|
|
|
|
|
|
@router.get(
|
|
"/log",
|
|
response_model=list[NotificationLogOut],
|
|
summary="Versandprotokoll",
|
|
description="Neueste Einträge zuerst.",
|
|
)
|
|
async def read_log(
|
|
session: DbSession,
|
|
rule_id: int | None = Query(default=None),
|
|
status_filter: NotificationStatus | None = Query(default=None, alias="status"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
) -> list[NotificationLog]:
|
|
stmt = select(NotificationLog).order_by(NotificationLog.sent_at.desc()).limit(limit)
|
|
if rule_id is not None:
|
|
stmt = stmt.where(NotificationLog.rule_id == rule_id)
|
|
if status_filter is not None:
|
|
stmt = stmt.where(NotificationLog.status == status_filter)
|
|
return list((await session.execute(stmt)).scalars().all())
|
|
|
|
|
|
@router.post(
|
|
"/test",
|
|
response_model=TestSendResponse,
|
|
summary="Testnachricht senden",
|
|
description="Verschickt eine Testnachricht über beide Kanäle. Nicht eingerichtete "
|
|
"Kanäle werden gemeldet, gelten aber nicht als Fehler.",
|
|
)
|
|
async def send_test_notification(payload: TestSendRequest | None = None) -> TestSendResponse:
|
|
ergebnisse = await send_test(payload.target if payload else None)
|
|
return TestSendResponse(
|
|
results=[TestResultOut.model_validate(eintrag) for eintrag in ergebnisse],
|
|
any_sent=any(eintrag.sent for eintrag in ergebnisse),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/run",
|
|
response_model=RunResponse,
|
|
summary="Lauf sofort ausführen",
|
|
description="Führt alle aktiven Regeln aus, ohne auf den Zeitplan zu warten. "
|
|
"Bereits gemeldete Ereignisse werden dabei übersprungen.",
|
|
)
|
|
async def run_now(
|
|
session: DbSession,
|
|
as_of: date | None = Query(default=None, description="Stichtag; Vorgabe ist heute."),
|
|
) -> RunResponse:
|
|
ergebnis = await run_all(session, as_of)
|
|
return RunResponse(
|
|
checked=ergebnis.checked,
|
|
sent=ergebnis.sent,
|
|
skipped=ergebnis.skipped,
|
|
failed=ergebnis.failed,
|
|
)
|