Files
moneyfyandClaude Opus 5 54c59c9f71 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
2026-09-09 17:04:30 +02:00

114 lines
3.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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