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
+113
View File
@@ -0,0 +1,113 @@
"""Schemata für Benachrichtigungsregeln, Protokoll und Testversand."""
from datetime import date, datetime
from pydantic import Field
from app.models.enums import (
NotificationChannel,
NotificationStatus,
NotificationType,
)
from app.schemas.common import ApiModel, InputModel
TYPE_HINTS = {
"due_soon": "Fälligkeiten innerhalb der Vorlaufzeit.",
"notice_deadline": "Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen.",
"budget_exceeded": "Überschrittene Budgets, einmal je Monat und Kategorie.",
"contract_renewal": "Verträge, die sich im kommenden Monat verlängern.",
}
class NotificationRuleCreate(InputModel):
type: NotificationType = Field(
description="; ".join(f"{k}: {v}" for k, v in TYPE_HINTS.items())
)
channel: NotificationChannel
lead_days: int = Field(
default=7, ge=0, le=365, description="Vorlauf in Tagen; nur für `due_soon` maßgeblich."
)
target: str | None = Field(
default=None,
description="Mailadresse bzw. Apprise-URL. Ohne Angabe gelten SMTP_FROM "
"beziehungsweise APPRISE_URLS.",
)
is_active: bool = True
class NotificationRuleUpdate(InputModel):
lead_days: int | None = Field(default=None, ge=0, le=365)
channel: NotificationChannel | None = None
target: str | None = None
is_active: bool | None = None
class NotificationRuleOut(ApiModel):
id: int
type: NotificationType
channel: NotificationChannel
lead_days: int
target: str | None
is_active: bool
created_at: datetime
class NotificationLogOut(ApiModel):
"""Ein Eintrag des Versandprotokolls."""
id: int
rule_id: int
ref_type: str
ref_id: str
dedupe_day: date = Field(
description="Zieltag des Ereignisses Grundlage des Duplikatsschutzes."
)
sent_at: datetime
status: NotificationStatus
error: str | None
class ChannelStatusOut(ApiModel):
"""Einrichtungsstand eines Kanals."""
channel: NotificationChannel
configured: bool
detail: str
class NotificationSettingsOut(ApiModel):
"""Überblick über Kanäle und Zeitplan."""
enabled: bool
scheduler_enabled: bool
run_at: str = Field(description="Uhrzeit des täglichen Laufs, etwa '07:00'.")
timezone: str
next_run_at: datetime | None
channels: list[ChannelStatusOut]
class TestSendRequest(InputModel):
target: str | None = Field(
default=None, description="Abweichendes Ziel; ohne Angabe gelten die Einstellungen."
)
class TestResultOut(ApiModel):
channel: NotificationChannel
configured: bool
sent: bool
error: str | None = None
class TestSendResponse(ApiModel):
results: list[TestResultOut]
any_sent: bool = Field(description="True, wenn mindestens ein Kanal zugestellt hat.")
class RunResponse(ApiModel):
"""Ergebnis eines manuell ausgelösten Laufs."""
checked: int
sent: int
skipped: int = Field(description="Bereits gemeldete Ereignisse.")
failed: int