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,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
|
||||
Reference in New Issue
Block a user