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:
moneyfy
2026-09-09 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
+176
View File
@@ -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,
)