feat(notifications): täglicher Lauf über SMTP und Apprise
- 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
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user