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,196 @@
|
||||
"""Versandkanäle für Benachrichtigungen: SMTP und Apprise.
|
||||
|
||||
Beide Bibliotheken arbeiten blockierend; die Aufrufe laufen deshalb in einem
|
||||
Thread, damit der Scheduler-Job den Event-Loop nicht anhält. Ein Kanal wirft
|
||||
`ChannelError`, wenn der Versand fehlschlägt – der Aufrufer protokolliert das.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.enums import NotificationChannel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_TIMEOUT_SECONDS = 20
|
||||
|
||||
|
||||
class ChannelError(RuntimeError):
|
||||
"""Der Versand über einen Kanal ist fehlgeschlagen."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Attachment:
|
||||
"""Ein eingebettetes Bild, das per Content-ID in der HTML-Mail steckt."""
|
||||
|
||||
cid: str
|
||||
"""Ohne spitze Klammern – im HTML als `cid:<wert>` referenziert."""
|
||||
path: Path
|
||||
mime: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Notification:
|
||||
"""Eine fertig formulierte Nachricht."""
|
||||
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
attachments: list[Attachment] = field(default_factory=list)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Channel(Protocol):
|
||||
"""Ein Weg, auf dem eine Nachricht den Nutzer erreicht."""
|
||||
|
||||
kind: NotificationChannel
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""False, wenn die nötigen Einstellungen fehlen – der Kanal wird übersprungen."""
|
||||
...
|
||||
|
||||
async def send(self, notification: Notification, target: str | None = None) -> None: ...
|
||||
|
||||
|
||||
# --- SMTP ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class SmtpChannel:
|
||||
"""Mailversand über einen SMTP-Server."""
|
||||
|
||||
kind = NotificationChannel.SMTP
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_from)
|
||||
|
||||
async def send(self, notification: Notification, target: str | None = None) -> None:
|
||||
if not self.is_configured():
|
||||
raise ChannelError("SMTP ist nicht konfiguriert (SMTP_HOST und SMTP_FROM fehlen).")
|
||||
|
||||
empfaenger = target or settings.smtp_from
|
||||
if not empfaenger:
|
||||
raise ChannelError("Kein Empfänger angegeben.")
|
||||
|
||||
nachricht = self._build(notification, empfaenger)
|
||||
try:
|
||||
await asyncio.to_thread(self._deliver, nachricht)
|
||||
except ChannelError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ChannelError(f"Mailversand fehlgeschlagen: {exc}") from exc
|
||||
|
||||
def _build(self, notification: Notification, empfaenger: str) -> EmailMessage:
|
||||
"""Baut eine Mail mit Text- und HTML-Teil sowie eingebetteten Logos."""
|
||||
nachricht = EmailMessage()
|
||||
nachricht["Subject"] = notification.subject
|
||||
nachricht["From"] = settings.smtp_from or ""
|
||||
nachricht["To"] = empfaenger
|
||||
nachricht.set_content(notification.text)
|
||||
|
||||
if notification.html is None:
|
||||
return nachricht
|
||||
|
||||
html = notification.html
|
||||
anhaenge: list[tuple[Attachment, str]] = []
|
||||
for anhang in notification.attachments:
|
||||
# Für jedes Bild eine echte Message-ID erzeugen und im HTML einsetzen.
|
||||
message_id = make_msgid()
|
||||
html = html.replace(f"cid:{anhang.cid}", f"cid:{message_id[1:-1]}")
|
||||
anhaenge.append((anhang, message_id))
|
||||
|
||||
nachricht.add_alternative(html, subtype="html")
|
||||
html_teil = nachricht.get_payload()[-1]
|
||||
|
||||
for anhang, message_id in anhaenge:
|
||||
try:
|
||||
inhalt = anhang.path.read_bytes()
|
||||
except OSError:
|
||||
logger.debug("Anhang %s konnte nicht gelesen werden.", anhang.path)
|
||||
continue
|
||||
haupttyp, _, untertyp = anhang.mime.partition("/")
|
||||
html_teil.add_related(inhalt, maintype=haupttyp, subtype=untertyp, cid=message_id)
|
||||
|
||||
return nachricht
|
||||
|
||||
def _deliver(self, nachricht: EmailMessage) -> None:
|
||||
"""Blockierender Teil des Versands."""
|
||||
host = settings.smtp_host or ""
|
||||
port = settings.smtp_port
|
||||
|
||||
if settings.smtp_use_ssl:
|
||||
verbindung = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS)
|
||||
else:
|
||||
verbindung = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
|
||||
|
||||
with verbindung as server:
|
||||
if settings.smtp_use_tls and not settings.smtp_use_ssl:
|
||||
server.starttls()
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.send_message(nachricht)
|
||||
|
||||
|
||||
# --- Apprise -------------------------------------------------------------------
|
||||
|
||||
|
||||
class AppriseChannel:
|
||||
"""Versand über Apprise – deckt ntfy, Gotify, Matrix, Discord und viele mehr ab."""
|
||||
|
||||
kind = NotificationChannel.APPRISE
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.apprise_url_list)
|
||||
|
||||
async def send(self, notification: Notification, target: str | None = None) -> None:
|
||||
# Eine Regel kann ein eigenes Ziel vorgeben, sonst gelten die globalen URLs.
|
||||
ziele = [target] if target else settings.apprise_url_list
|
||||
if not ziele:
|
||||
raise ChannelError("Apprise ist nicht konfiguriert (APPRISE_URLS fehlt).")
|
||||
|
||||
try:
|
||||
erfolg = await asyncio.to_thread(self._deliver, notification, ziele)
|
||||
except Exception as exc:
|
||||
raise ChannelError(f"Apprise-Versand fehlgeschlagen: {exc}") from exc
|
||||
|
||||
if not erfolg:
|
||||
raise ChannelError("Apprise konnte die Nachricht an kein Ziel zustellen.")
|
||||
|
||||
def _deliver(self, notification: Notification, ziele: list[str]) -> bool:
|
||||
import apprise
|
||||
|
||||
sammlung = apprise.Apprise()
|
||||
for ziel in ziele:
|
||||
if not sammlung.add(ziel):
|
||||
logger.warning("Apprise-Ziel konnte nicht gelesen werden: %s", ziel)
|
||||
|
||||
if len(sammlung) == 0:
|
||||
raise ChannelError("Kein gültiges Apprise-Ziel.")
|
||||
|
||||
return bool(sammlung.notify(title=notification.subject, body=notification.text))
|
||||
|
||||
|
||||
# --- Registrierung -------------------------------------------------------------
|
||||
|
||||
_CHANNELS: dict[NotificationChannel, Channel] = {
|
||||
NotificationChannel.SMTP: SmtpChannel(),
|
||||
NotificationChannel.APPRISE: AppriseChannel(),
|
||||
}
|
||||
|
||||
|
||||
def get_channel(kind: NotificationChannel) -> Channel:
|
||||
kanal = _CHANNELS.get(kind)
|
||||
if kanal is None: # pragma: no cover - alle Enum-Werte sind registriert
|
||||
raise ChannelError(f"Unbekannter Kanal: {kind}")
|
||||
return kanal
|
||||
|
||||
|
||||
def configured_channels() -> list[Channel]:
|
||||
"""Alle Kanäle, die tatsächlich einsatzbereit sind."""
|
||||
return [kanal for kanal in _CHANNELS.values() if kanal.is_configured()]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Benachrichtigungen: Ereignisse sammeln, Duplikate ausschließen, versenden.
|
||||
|
||||
Der Duplikatsschutz hängt nicht am Versandtag, sondern am Zieltag des Ereignisses:
|
||||
Eine Fälligkeit am 15.03. wird genau einmal gemeldet, egal an welchem Tag des
|
||||
Vorlaufs der Job läuft. Ein zweiter Lauf am selben Tag erzeugt daher ebenso wenig
|
||||
eine zweite Nachricht wie ein Lauf am Folgetag.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today, utcnow
|
||||
from app.core.config import settings
|
||||
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
||||
from app.models.enums import (
|
||||
EntryKind,
|
||||
NotificationChannel,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
)
|
||||
from app.services.channels import Attachment, ChannelError, Notification, get_channel
|
||||
from app.services.occurrences import load_recurrences
|
||||
from app.services.recurrence import contract_term
|
||||
from app.services.reports import budget_status, flows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
# Vorlaufstufen für Kündigungsfristen gemäß Fachspezifikation.
|
||||
NOTICE_STAGES = (30, 14, 7)
|
||||
|
||||
REF_OCCURRENCE = "occurrence"
|
||||
REF_RECURRENCE = "recurrence"
|
||||
REF_BUDGET = "budget"
|
||||
|
||||
TYPE_LABELS: dict[NotificationType, str] = {
|
||||
NotificationType.DUE_SOON: "Bald fällig",
|
||||
NotificationType.NOTICE_DEADLINE: "Kündigungsfrist läuft ab",
|
||||
NotificationType.BUDGET_EXCEEDED: "Budget überschritten",
|
||||
NotificationType.CONTRACT_RENEWAL: "Vertrag verlängert sich",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Event:
|
||||
"""Ein meldenswertes Ereignis."""
|
||||
|
||||
ref_type: str
|
||||
ref_id: str
|
||||
"""Stabiler Schlüssel innerhalb des ref_type."""
|
||||
dedupe_day: date
|
||||
"""Zieltag des Ereignisses – nicht der Versandtag."""
|
||||
headline: str
|
||||
detail: str
|
||||
amount: Decimal | None = None
|
||||
merchant_id: int | None = None
|
||||
on: date | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunResult:
|
||||
"""Ergebnis eines Laufs."""
|
||||
|
||||
checked: int = 0
|
||||
sent: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
by_rule: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
# --- Ereignisse sammeln --------------------------------------------------------
|
||||
|
||||
|
||||
def _money(amount: Decimal | None) -> str:
|
||||
if amount is None:
|
||||
return ""
|
||||
return f"{amount:,.2f} €".replace(",", "#").replace(".", ",").replace("#", ".")
|
||||
|
||||
|
||||
def _day(value: date) -> str:
|
||||
return value.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
async def collect_due_soon(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Fälligkeiten innerhalb des Vorlaufs."""
|
||||
ende = as_of + timedelta(days=max(rule.lead_days, 0))
|
||||
|
||||
ereignisse: list[Event] = []
|
||||
for eintrag in await flows(session, as_of, ende):
|
||||
# Einmalige Buchungen sind bereits erfasst und brauchen keine Erinnerung.
|
||||
if eintrag.source != "recurrence" or eintrag.recurrence_id is None:
|
||||
continue
|
||||
if eintrag.is_confirmed:
|
||||
continue
|
||||
|
||||
richtung = "Einkunft" if eintrag.kind is EntryKind.INCOME else "Zahlung"
|
||||
ereignisse.append(
|
||||
Event(
|
||||
ref_type=REF_OCCURRENCE,
|
||||
ref_id=f"{eintrag.recurrence_id}:{eintrag.occurrence_date}",
|
||||
dedupe_day=eintrag.on,
|
||||
headline=eintrag.title,
|
||||
detail=f"{richtung} am {_day(eintrag.on)} über {_money(eintrag.amount)}",
|
||||
amount=eintrag.amount,
|
||||
merchant_id=eintrag.merchant_id,
|
||||
on=eintrag.on,
|
||||
)
|
||||
)
|
||||
return ereignisse
|
||||
|
||||
|
||||
async def collect_notice_deadlines(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen.
|
||||
|
||||
Jede Stufe wird einmal gemeldet; der Schlüssel enthält deshalb die Stufe.
|
||||
"""
|
||||
# Aufsteigend, damit die *engste* zutreffende Stufe gewinnt: bei 14 Resttagen
|
||||
# ist es die 14er-Stufe, nicht erneut die 30er.
|
||||
stufen = sorted({*NOTICE_STAGES, rule.lead_days} - {0})
|
||||
|
||||
ereignisse: list[Event] = []
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.contract_cancelled_at is not None:
|
||||
continue
|
||||
laufzeit = contract_term(recurrence, as_of)
|
||||
if laufzeit is None or laufzeit.notice_deadline is None:
|
||||
continue
|
||||
|
||||
verbleibend = (laufzeit.notice_deadline - as_of).days
|
||||
if verbleibend < 0:
|
||||
continue
|
||||
|
||||
# Die erste Stufe, die der Termin gerade erreicht oder unterschritten hat.
|
||||
stufe = next((wert for wert in stufen if verbleibend <= wert), None)
|
||||
if stufe is None:
|
||||
continue
|
||||
|
||||
ereignisse.append(
|
||||
Event(
|
||||
ref_type=REF_RECURRENCE,
|
||||
ref_id=f"{recurrence.id}:{stufe}",
|
||||
dedupe_day=laufzeit.notice_deadline,
|
||||
headline=recurrence.title,
|
||||
detail=(
|
||||
f"Kündigung bis {_day(laufzeit.notice_deadline)} möglich "
|
||||
f"(noch {verbleibend} Tage), Laufzeit endet am {_day(laufzeit.term_end)}"
|
||||
),
|
||||
amount=recurrence.amount,
|
||||
merchant_id=recurrence.merchant_id,
|
||||
on=laufzeit.notice_deadline,
|
||||
)
|
||||
)
|
||||
return ereignisse
|
||||
|
||||
|
||||
async def collect_budget_exceeded(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Überschrittene Budgets – höchstens einmal je Monat und Kategorie."""
|
||||
monat = month_start(as_of)
|
||||
|
||||
return [
|
||||
Event(
|
||||
ref_type=REF_BUDGET,
|
||||
ref_id=str(eintrag.category_id),
|
||||
dedupe_day=monat,
|
||||
headline=eintrag.category_name,
|
||||
detail=(
|
||||
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
|
||||
f"({round(float(eintrag.ratio) * 100)} %), "
|
||||
f"{_money(abs(eintrag.remaining))} zu viel"
|
||||
),
|
||||
amount=eintrag.spent,
|
||||
on=month_end(monat),
|
||||
)
|
||||
for eintrag in await budget_status(session, monat)
|
||||
if eintrag.state == "exceeded"
|
||||
]
|
||||
|
||||
|
||||
async def collect_contract_renewals(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Verträge, die sich im kommenden Monat automatisch verlängern."""
|
||||
fenster_ende = month_end(add_months(as_of, 1))
|
||||
|
||||
ereignisse: list[Event] = []
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.contract_cancelled_at is not None:
|
||||
continue
|
||||
laufzeit = contract_term(recurrence, as_of)
|
||||
if laufzeit is None or laufzeit.renews_on is None:
|
||||
continue
|
||||
if not as_of <= laufzeit.renews_on <= fenster_ende:
|
||||
continue
|
||||
|
||||
verlaengerung = recurrence.contract_auto_renew_months
|
||||
ereignisse.append(
|
||||
Event(
|
||||
ref_type=REF_RECURRENCE,
|
||||
ref_id=str(recurrence.id),
|
||||
dedupe_day=laufzeit.renews_on,
|
||||
headline=recurrence.title,
|
||||
detail=(
|
||||
f"Verlängert sich am {_day(laufzeit.renews_on)}"
|
||||
+ (f" um {verlaengerung} Monate" if verlaengerung else "")
|
||||
),
|
||||
amount=recurrence.amount,
|
||||
merchant_id=recurrence.merchant_id,
|
||||
on=laufzeit.renews_on,
|
||||
)
|
||||
)
|
||||
return ereignisse
|
||||
|
||||
|
||||
COLLECTORS = {
|
||||
NotificationType.DUE_SOON: collect_due_soon,
|
||||
NotificationType.NOTICE_DEADLINE: collect_notice_deadlines,
|
||||
NotificationType.BUDGET_EXCEEDED: collect_budget_exceeded,
|
||||
NotificationType.CONTRACT_RENEWAL: collect_contract_renewals,
|
||||
}
|
||||
|
||||
|
||||
# --- Nachricht bauen -----------------------------------------------------------
|
||||
|
||||
|
||||
async def _logo_attachments(session: AsyncSession, events: list[Event]) -> dict[int, Attachment]:
|
||||
"""Lädt die Logodateien der beteiligten Firmen für den Mailanhang."""
|
||||
firmen_ids = {ereignis.merchant_id for ereignis in events if ereignis.merchant_id}
|
||||
if not firmen_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(Merchant, LogoAsset)
|
||||
.join(LogoAsset, LogoAsset.id == Merchant.logo_asset_id)
|
||||
.where(Merchant.id.in_(firmen_ids))
|
||||
)
|
||||
|
||||
anhaenge: dict[int, Attachment] = {}
|
||||
for merchant, asset in (await session.execute(stmt)).all():
|
||||
pfad = Path(settings.logo_storage_dir) / asset.file_path
|
||||
if not pfad.exists():
|
||||
continue
|
||||
anhaenge[merchant.id] = Attachment(cid=f"logo-{merchant.id}", path=pfad, mime=asset.mime)
|
||||
return anhaenge
|
||||
|
||||
|
||||
def build_notification(
|
||||
rule_type: NotificationType,
|
||||
events: list[Event],
|
||||
attachments: dict[int, Attachment],
|
||||
) -> Notification:
|
||||
"""Formuliert aus den Ereignissen eine Nachricht in Text und HTML."""
|
||||
ueberschrift = TYPE_LABELS[rule_type]
|
||||
betreff = (
|
||||
f"moneyfy: {ueberschrift}"
|
||||
if len(events) == 1
|
||||
else f"moneyfy: {ueberschrift} ({len(events)})"
|
||||
)
|
||||
|
||||
text_zeilen = [ueberschrift, ""]
|
||||
for ereignis in events:
|
||||
text_zeilen.append(f"• {ereignis.headline}: {ereignis.detail}")
|
||||
text_zeilen.extend(["", settings.public_base_url])
|
||||
|
||||
zeilen_html: list[str] = []
|
||||
for ereignis in events:
|
||||
anhang = attachments.get(ereignis.merchant_id or -1)
|
||||
bild = (
|
||||
f'<img src="cid:{anhang.cid}" alt="" width="28" height="28" '
|
||||
'style="border-radius:6px;vertical-align:middle;margin-right:10px">'
|
||||
if anhang
|
||||
else ""
|
||||
)
|
||||
zeilen_html.append(
|
||||
'<tr><td style="padding:10px 0;border-bottom:1px solid #e2e8f0">'
|
||||
f"{bild}"
|
||||
f'<strong style="color:#0f172a">{escape(ereignis.headline)}</strong><br>'
|
||||
f'<span style="color:#475569;font-size:14px">{escape(ereignis.detail)}</span>'
|
||||
"</td></tr>"
|
||||
)
|
||||
|
||||
html = f"""<!doctype html>
|
||||
<html lang="de"><body style="margin:0;padding:24px;background:#f8fafc;
|
||||
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif">
|
||||
<div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:14px;
|
||||
border:1px solid #e2e8f0;padding:24px">
|
||||
<h1 style="margin:0 0 4px;font-size:18px;color:#0f172a">{escape(ueberschrift)}</h1>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:#64748b">
|
||||
{len(events)} {"Eintrag" if len(events) == 1 else "Einträge"}
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse">{"".join(zeilen_html)}</table>
|
||||
<p style="margin:20px 0 0;font-size:13px">
|
||||
<a href="{escape(settings.public_base_url)}" style="color:#16a34a">In moneyfy öffnen</a>
|
||||
</p>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
beteiligte = {
|
||||
ereignis.merchant_id for ereignis in events if ereignis.merchant_id in attachments
|
||||
}
|
||||
return Notification(
|
||||
subject=betreff,
|
||||
text="\n".join(text_zeilen),
|
||||
html=html,
|
||||
attachments=[attachments[firma_id] for firma_id in beteiligte if firma_id],
|
||||
)
|
||||
|
||||
|
||||
# --- Versand und Protokoll -----------------------------------------------------
|
||||
|
||||
|
||||
async def _existing_log(
|
||||
session: AsyncSession, rule_id: int, event: Event
|
||||
) -> NotificationLog | None:
|
||||
stmt = select(NotificationLog).where(
|
||||
NotificationLog.rule_id == rule_id,
|
||||
NotificationLog.ref_type == event.ref_type,
|
||||
NotificationLog.ref_id == event.ref_id,
|
||||
NotificationLog.dedupe_day == event.dedupe_day,
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def pending_events(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> tuple[list[Event], list[Event], dict[str, NotificationLog]]:
|
||||
"""Trennt neue Ereignisse von bereits gemeldeten.
|
||||
|
||||
Ein zuvor fehlgeschlagener Versand gilt als offen und wird erneut versucht;
|
||||
der vorhandene Protokolleintrag wird dabei aktualisiert statt neu angelegt.
|
||||
"""
|
||||
sammler = COLLECTORS[rule.type]
|
||||
alle = await sammler(session, rule, as_of)
|
||||
|
||||
offen: list[Event] = []
|
||||
erledigt: list[Event] = []
|
||||
vorhandene: dict[str, NotificationLog] = {}
|
||||
|
||||
for ereignis in alle:
|
||||
eintrag = await _existing_log(session, rule.id, ereignis)
|
||||
if eintrag is None:
|
||||
offen.append(ereignis)
|
||||
continue
|
||||
if eintrag.status is NotificationStatus.FAILED:
|
||||
offen.append(ereignis)
|
||||
vorhandene[f"{ereignis.ref_type}:{ereignis.ref_id}"] = eintrag
|
||||
continue
|
||||
erledigt.append(ereignis)
|
||||
|
||||
return offen, erledigt, vorhandene
|
||||
|
||||
|
||||
async def _record(
|
||||
session: AsyncSession,
|
||||
rule: NotificationRule,
|
||||
events: list[Event],
|
||||
existing: dict[str, NotificationLog],
|
||||
status: NotificationStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""Schreibt das Versandprotokoll für alle Ereignisse einer Nachricht."""
|
||||
zeitpunkt = utcnow()
|
||||
for ereignis in events:
|
||||
schluessel = f"{ereignis.ref_type}:{ereignis.ref_id}"
|
||||
eintrag = existing.get(schluessel)
|
||||
if eintrag is not None:
|
||||
eintrag.status = status
|
||||
eintrag.error = error
|
||||
eintrag.sent_at = zeitpunkt
|
||||
continue
|
||||
|
||||
session.add(
|
||||
NotificationLog(
|
||||
rule_id=rule.id,
|
||||
ref_type=ereignis.ref_type,
|
||||
ref_id=ereignis.ref_id,
|
||||
dedupe_day=ereignis.dedupe_day,
|
||||
sent_at=zeitpunkt,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def run_rule(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date | None = None
|
||||
) -> tuple[int, int, int]:
|
||||
"""Führt eine Regel aus. Liefert (geprüft, versendet, übersprungen)."""
|
||||
stichtag = as_of or today()
|
||||
offen, erledigt, vorhandene = await pending_events(session, rule, stichtag)
|
||||
|
||||
if not offen:
|
||||
return len(offen) + len(erledigt), 0, len(erledigt)
|
||||
|
||||
anhaenge = await _logo_attachments(session, offen)
|
||||
nachricht = build_notification(rule.type, offen, anhaenge)
|
||||
|
||||
try:
|
||||
await get_channel(rule.channel).send(nachricht, rule.target)
|
||||
except ChannelError as fehler:
|
||||
logger.warning("Regel %s konnte nicht zugestellt werden: %s", rule.id, fehler)
|
||||
await _record(session, rule, offen, vorhandene, NotificationStatus.FAILED, str(fehler))
|
||||
return len(offen) + len(erledigt), 0, len(erledigt)
|
||||
|
||||
await _record(session, rule, offen, vorhandene, NotificationStatus.SENT, None)
|
||||
return len(offen) + len(erledigt), len(offen), len(erledigt)
|
||||
|
||||
|
||||
async def run_all(session: AsyncSession, as_of: date | None = None) -> RunResult:
|
||||
"""Führt alle aktiven Regeln aus – der tägliche Lauf des Schedulers."""
|
||||
if not settings.notifications_enabled:
|
||||
logger.info("Benachrichtigungen sind abgeschaltet.")
|
||||
return RunResult()
|
||||
|
||||
stichtag = as_of or today()
|
||||
stmt = select(NotificationRule).where(NotificationRule.is_active.is_(True))
|
||||
regeln = list((await session.execute(stmt)).scalars())
|
||||
|
||||
ergebnis = RunResult()
|
||||
for regel in regeln:
|
||||
try:
|
||||
geprueft, versendet, uebersprungen = await run_rule(session, regel, stichtag)
|
||||
except Exception:
|
||||
# Eine fehlerhafte Regel darf die übrigen nicht verhindern.
|
||||
logger.exception("Regel %s ist fehlgeschlagen.", regel.id)
|
||||
ergebnis.failed += 1
|
||||
continue
|
||||
|
||||
ergebnis.checked += geprueft
|
||||
ergebnis.sent += versendet
|
||||
ergebnis.skipped += uebersprungen
|
||||
if versendet:
|
||||
ergebnis.by_rule[regel.id] = versendet
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Benachrichtigungslauf: %d geprüft, %d versendet, %d übersprungen, %d fehlerhaft.",
|
||||
ergebnis.checked,
|
||||
ergebnis.sent,
|
||||
ergebnis.skipped,
|
||||
ergebnis.failed,
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Testversand ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification() -> Notification:
|
||||
"""Eine kurze Nachricht, mit der sich die Einrichtung prüfen lässt."""
|
||||
zeitpunkt = utcnow().astimezone().strftime("%d.%m.%Y um %H:%M Uhr")
|
||||
return Notification(
|
||||
subject="moneyfy: Testnachricht",
|
||||
text=(
|
||||
"Das ist eine Testnachricht von moneyfy.\n"
|
||||
f"Gesendet am {zeitpunkt}.\n\n"
|
||||
"Wenn du sie liest, ist der Kanal richtig eingerichtet.\n\n"
|
||||
f"{settings.public_base_url}"
|
||||
),
|
||||
html=f"""<!doctype html>
|
||||
<html lang="de"><body style="margin:0;padding:24px;background:#f8fafc;
|
||||
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif">
|
||||
<div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:14px;
|
||||
border:1px solid #e2e8f0;padding:24px">
|
||||
<h1 style="margin:0 0 8px;font-size:18px;color:#0f172a">Testnachricht</h1>
|
||||
<p style="margin:0;font-size:14px;color:#475569">
|
||||
Wenn du das liest, ist der Kanal richtig eingerichtet.<br>
|
||||
Gesendet am {zeitpunkt}.
|
||||
</p>
|
||||
<p style="margin:20px 0 0;font-size:13px">
|
||||
<a href="{escape(settings.public_base_url)}" style="color:#16a34a">moneyfy öffnen</a>
|
||||
</p>
|
||||
</div>
|
||||
</body></html>""",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TestResult:
|
||||
"""Ergebnis eines Testversands je Kanal."""
|
||||
|
||||
channel: NotificationChannel
|
||||
configured: bool
|
||||
sent: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def send_test(target: str | None = None) -> list[TestResult]:
|
||||
"""Verschickt eine Testnachricht über beide Kanäle.
|
||||
|
||||
Nicht eingerichtete Kanäle werden gemeldet, aber nicht als Fehler gewertet.
|
||||
"""
|
||||
nachricht = test_notification()
|
||||
|
||||
ergebnisse: list[TestResult] = []
|
||||
for art in (NotificationChannel.SMTP, NotificationChannel.APPRISE):
|
||||
kanal = get_channel(art)
|
||||
if not kanal.is_configured():
|
||||
ergebnisse.append(TestResult(channel=art, configured=False, sent=False))
|
||||
continue
|
||||
try:
|
||||
await kanal.send(nachricht, target)
|
||||
except ChannelError as fehler:
|
||||
ergebnisse.append(
|
||||
TestResult(channel=art, configured=True, sent=False, error=str(fehler))
|
||||
)
|
||||
continue
|
||||
ergebnisse.append(TestResult(channel=art, configured=True, sent=True))
|
||||
|
||||
return ergebnisse
|
||||
|
||||
|
||||
def group_by_type(events: list[Event]) -> dict[str, list[Event]]:
|
||||
"""Hilfsfunktion für Vorschauen in der Oberfläche."""
|
||||
gruppen: dict[str, list[Event]] = defaultdict(list)
|
||||
for ereignis in events:
|
||||
gruppen[ereignis.ref_type].append(ereignis)
|
||||
return dict(gruppen)
|
||||
Reference in New Issue
Block a user