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:
@@ -96,6 +96,21 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den
|
||||
Vorzeichen im Text vorbehalten.
|
||||
|
||||
- Benachrichtigungen über SMTP (HTML-Mail mit eingebetteten Firmenlogos) und
|
||||
Apprise, gesteuert über Regeln je Anlass und Kanal.
|
||||
- Vier Anlässe: bald fällige Posten, Kündigungsfristen (30, 14 und 7 Tage vor
|
||||
dem Termin), überschrittene Budgets und anstehende Vertragsverlängerungen.
|
||||
- APScheduler-Job täglich um 07:00 `Europe/Berlin`, der zugleich abgelaufene
|
||||
Sitzungen aufräumt; verpasste Läufe werden einmal nachgeholt.
|
||||
- Duplikatsschutz über `(rule_id, ref_type, ref_id, Zieltag)`. Maßgeblich ist der
|
||||
Zieltag des Ereignisses, nicht der Versandtag – ein zweiter Lauf am selben oder
|
||||
am nächsten Tag erzeugt keine zweite Nachricht. Ein fehlgeschlagener Versand
|
||||
wird beim nächsten Lauf erneut versucht.
|
||||
- `POST /api/notifications/test` verschickt eine Testnachricht über beide Kanäle,
|
||||
`POST /api/notifications/run` führt einen Lauf sofort aus.
|
||||
- Benachrichtigungen sind in den Einstellungen pflegbar, samt Einrichtungsstand
|
||||
der Kanäle und Versandprotokoll.
|
||||
|
||||
### Geändert
|
||||
|
||||
- `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in
|
||||
|
||||
@@ -83,6 +83,22 @@ vollständige, kommentierte Liste. Die wichtigsten:
|
||||
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
|
||||
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
Ein Job läuft täglich um 07:00 Uhr (`Europe/Berlin`) im Anwendungsprozess – ohne
|
||||
Redis, ohne Celery. Er prüft vier Anlässe: bald fällige Posten, Kündigungsfristen
|
||||
30, 14 und 7 Tage vor dem Termin, überschrittene Budgets und Vertragsverlängerungen
|
||||
im kommenden Monat.
|
||||
|
||||
Der Duplikatsschutz hängt am **Zieltag des Ereignisses**, nicht am Versandtag:
|
||||
Eine Fälligkeit am 15.03. wird genau einmal gemeldet, unabhängig davon, an
|
||||
welchem Tag des Vorlaufs der Job läuft. Ein fehlgeschlagener Versand gilt als
|
||||
offen und wird beim nächsten Lauf erneut versucht.
|
||||
|
||||
Als Kanäle stehen SMTP (HTML-Mail mit eingebetteten Firmenlogos) und Apprise zur
|
||||
Verfügung. Über *Einstellungen → Benachrichtigungen* lassen sich Regeln pflegen,
|
||||
eine Testnachricht verschicken und das Versandprotokoll einsehen.
|
||||
|
||||
## Diagramme
|
||||
|
||||
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.api.routes import (
|
||||
logos,
|
||||
me,
|
||||
merchants,
|
||||
notifications,
|
||||
occurrences,
|
||||
recurrences,
|
||||
reports,
|
||||
@@ -45,5 +46,6 @@ protected.include_router(budgets.templates)
|
||||
protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
protected.include_router(export.router)
|
||||
protected.include_router(notifications.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||
from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -25,8 +26,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Start- und Stopplogik der Anwendung."""
|
||||
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
await _bootstrap()
|
||||
start_scheduler()
|
||||
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
||||
yield
|
||||
shutdown_scheduler()
|
||||
await engine.dispose()
|
||||
logger.info("moneyfy wird beendet")
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Zeitgesteuerte Aufgaben.
|
||||
|
||||
APScheduler läuft im selben Prozess wie die Anwendung – kein Redis, kein Celery.
|
||||
Der tägliche Lauf prüft die Benachrichtigungsregeln und räumt abgelaufene
|
||||
Sitzungen auf.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.clock import tz
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.auth import purge_expired_refresh_tokens
|
||||
from app.services.notifications import run_all
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_JOB_ID = "moneyfy-daily"
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
async def daily_job() -> None:
|
||||
"""Täglicher Lauf. Fehler werden protokolliert, beenden den Scheduler aber nicht."""
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
await run_all(session)
|
||||
|
||||
entfernt = await purge_expired_refresh_tokens(session)
|
||||
await session.commit()
|
||||
if entfernt:
|
||||
logger.info("%d abgelaufene Sitzungen entfernt.", entfernt)
|
||||
except Exception:
|
||||
logger.exception("Der tägliche Lauf ist fehlgeschlagen.")
|
||||
|
||||
|
||||
def start_scheduler() -> AsyncIOScheduler | None:
|
||||
"""Startet den Scheduler, sofern er eingeschaltet ist."""
|
||||
global _scheduler
|
||||
|
||||
if not settings.scheduler_enabled:
|
||||
logger.info("Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false).")
|
||||
return None
|
||||
if _scheduler is not None:
|
||||
return _scheduler
|
||||
|
||||
_scheduler = AsyncIOScheduler(timezone=tz())
|
||||
_scheduler.add_job(
|
||||
daily_job,
|
||||
CronTrigger(
|
||||
hour=settings.notification_hour,
|
||||
minute=settings.notification_minute,
|
||||
timezone=tz(),
|
||||
),
|
||||
id=DAILY_JOB_ID,
|
||||
name="Benachrichtigungen und Aufräumen",
|
||||
# Verpasste Läufe (etwa nach einem Neustart) einmal nachholen.
|
||||
misfire_grace_time=3600,
|
||||
coalesce=True,
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
|
||||
logger.info(
|
||||
"Scheduler gestartet – täglicher Lauf um %02d:%02d %s.",
|
||||
settings.notification_hour,
|
||||
settings.notification_minute,
|
||||
settings.timezone,
|
||||
)
|
||||
return _scheduler
|
||||
|
||||
|
||||
def shutdown_scheduler() -> None:
|
||||
"""Hält den Scheduler beim Beenden der Anwendung an."""
|
||||
global _scheduler
|
||||
|
||||
if _scheduler is None:
|
||||
return
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Scheduler beendet.")
|
||||
|
||||
|
||||
def next_run_time():
|
||||
"""Nächster geplanter Lauf, für die Anzeige in den Einstellungen."""
|
||||
if _scheduler is None:
|
||||
return None
|
||||
job = _scheduler.get_job(DAILY_JOB_ID)
|
||||
return job.next_run_time if job else None
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Tests der Benachrichtigungen: Ereignisse, Duplikatsschutz und Versand."""
|
||||
|
||||
import smtplib
|
||||
from datetime import date, timedelta
|
||||
from email.message import EmailMessage
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import NotificationLog, NotificationRule
|
||||
from app.models.enums import NotificationChannel, NotificationStatus, NotificationType
|
||||
from app.services import notifications as service
|
||||
from app.services.channels import AppriseChannel, ChannelError, SmtpChannel
|
||||
|
||||
|
||||
def text_of(nachricht: EmailMessage) -> str:
|
||||
"""Der reine Textteil einer mehrteiligen Mail."""
|
||||
teil = nachricht.get_body(preferencelist=("plain",))
|
||||
assert teil is not None, "Die Mail hat keinen Textteil."
|
||||
return teil.get_content()
|
||||
|
||||
|
||||
# --- Doppel für die Kanäle ------------------------------------------------------
|
||||
|
||||
|
||||
class FakeSmtp:
|
||||
"""Ersetzt `smtplib.SMTP` und merkt sich die versendeten Nachrichten."""
|
||||
|
||||
versendet: ClassVar[list[EmailMessage]] = []
|
||||
fehler: ClassVar[Exception | None] = None
|
||||
|
||||
def __init__(self, host: str, port: int, timeout: int = 0) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def __enter__(self) -> "FakeSmtp":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def starttls(self) -> None:
|
||||
return None
|
||||
|
||||
def login(self, user: str, password: str) -> None:
|
||||
return None
|
||||
|
||||
def send_message(self, nachricht: EmailMessage) -> None:
|
||||
if FakeSmtp.fehler is not None:
|
||||
raise FakeSmtp.fehler
|
||||
FakeSmtp.versendet.append(nachricht)
|
||||
|
||||
|
||||
class FakeApprise:
|
||||
"""Ersetzt `apprise.Apprise`."""
|
||||
|
||||
versendet: ClassVar[list[tuple[str, str]]] = []
|
||||
erfolg: ClassVar[bool] = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ziele: list[str] = []
|
||||
|
||||
def add(self, ziel: str) -> bool:
|
||||
self.ziele.append(ziel)
|
||||
return True
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.ziele)
|
||||
|
||||
def notify(self, title: str, body: str) -> bool:
|
||||
if FakeApprise.erfolg:
|
||||
FakeApprise.versendet.append((title, body))
|
||||
return FakeApprise.erfolg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp(monkeypatch: pytest.MonkeyPatch) -> type[FakeSmtp]:
|
||||
"""Richtet SMTP ein und fängt den Versand ab."""
|
||||
FakeSmtp.versendet = []
|
||||
FakeSmtp.fehler = None
|
||||
monkeypatch.setattr(smtplib, "SMTP", FakeSmtp)
|
||||
monkeypatch.setattr(settings, "smtp_host", "mail.example.org")
|
||||
monkeypatch.setattr(settings, "smtp_from", "moneyfy@example.org")
|
||||
monkeypatch.setattr(settings, "smtp_use_tls", False)
|
||||
monkeypatch.setattr(settings, "smtp_use_ssl", False)
|
||||
return FakeSmtp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def apprise(monkeypatch: pytest.MonkeyPatch) -> type[FakeApprise]:
|
||||
"""Richtet Apprise ein und fängt den Versand ab."""
|
||||
import apprise as apprise_modul
|
||||
|
||||
FakeApprise.versendet = []
|
||||
FakeApprise.erfolg = True
|
||||
monkeypatch.setattr(apprise_modul, "Apprise", FakeApprise)
|
||||
monkeypatch.setattr(settings, "apprise_urls", "ntfy://example.org/moneyfy")
|
||||
return FakeApprise
|
||||
|
||||
|
||||
async def regel(
|
||||
session: AsyncSession,
|
||||
typ: NotificationType,
|
||||
*,
|
||||
kanal: NotificationChannel = NotificationChannel.SMTP,
|
||||
lead_days: int = 7,
|
||||
target: str | None = None,
|
||||
) -> NotificationRule:
|
||||
eintrag = NotificationRule(
|
||||
type=typ, channel=kanal, lead_days=lead_days, target=target, is_active=True
|
||||
)
|
||||
session.add(eintrag)
|
||||
await session.flush()
|
||||
return eintrag
|
||||
|
||||
|
||||
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
# --- Kanäle ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_smtp_baut_eine_mail_mit_text_und_html(smtp: type[FakeSmtp]) -> None:
|
||||
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
|
||||
|
||||
assert len(smtp.versendet) == 1
|
||||
nachricht = smtp.versendet[0]
|
||||
assert nachricht["To"] == "jemand@example.org"
|
||||
assert nachricht["From"] == "moneyfy@example.org"
|
||||
assert nachricht["Subject"] == "moneyfy: Testnachricht"
|
||||
# Text und HTML liegen als Alternativen nebeneinander.
|
||||
typen = {teil.get_content_type() for teil in nachricht.walk()}
|
||||
assert "text/plain" in typen
|
||||
assert "text/html" in typen
|
||||
|
||||
|
||||
async def test_smtp_ohne_konfiguration_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "smtp_host", None)
|
||||
|
||||
assert SmtpChannel().is_configured() is False
|
||||
with pytest.raises(ChannelError, match="nicht konfiguriert"):
|
||||
await SmtpChannel().send(service.test_notification())
|
||||
|
||||
|
||||
async def test_smtp_reicht_serverfehler_als_channelerror_durch(
|
||||
smtp: type[FakeSmtp],
|
||||
) -> None:
|
||||
smtp.fehler = smtplib.SMTPRecipientsRefused({})
|
||||
|
||||
with pytest.raises(ChannelError, match="Mailversand fehlgeschlagen"):
|
||||
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
|
||||
|
||||
|
||||
async def test_apprise_versendet_titel_und_text(apprise: type[FakeApprise]) -> None:
|
||||
await AppriseChannel().send(service.test_notification())
|
||||
|
||||
assert len(apprise.versendet) == 1
|
||||
titel, text = apprise.versendet[0]
|
||||
assert titel == "moneyfy: Testnachricht"
|
||||
assert "Testnachricht von moneyfy" in text
|
||||
|
||||
|
||||
async def test_apprise_ohne_ziel_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "apprise_urls", None)
|
||||
|
||||
assert AppriseChannel().is_configured() is False
|
||||
with pytest.raises(ChannelError, match="nicht konfiguriert"):
|
||||
await AppriseChannel().send(service.test_notification())
|
||||
|
||||
|
||||
async def test_apprise_meldet_fehlgeschlagene_zustellung(
|
||||
apprise: type[FakeApprise],
|
||||
) -> None:
|
||||
apprise.erfolg = False
|
||||
|
||||
with pytest.raises(ChannelError, match="kein Ziel"):
|
||||
await AppriseChannel().send(service.test_notification())
|
||||
|
||||
|
||||
# --- Testversand ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_testversand_ueber_beide_kanaele(
|
||||
auth_client: AsyncClient, smtp: type[FakeSmtp], apprise: type[FakeApprise]
|
||||
) -> None:
|
||||
"""Akzeptanzkriterium: der Testversand erreicht beide Kanäle."""
|
||||
antwort = await auth_client.post("/api/notifications/test", json={})
|
||||
|
||||
assert antwort.status_code == 200
|
||||
body = antwort.json()
|
||||
assert body["any_sent"] is True
|
||||
|
||||
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
|
||||
assert nach_kanal["smtp"] == {
|
||||
"channel": "smtp",
|
||||
"configured": True,
|
||||
"sent": True,
|
||||
"error": None,
|
||||
}
|
||||
assert nach_kanal["apprise"]["sent"] is True
|
||||
|
||||
assert len(smtp.versendet) == 1
|
||||
assert len(apprise.versendet) == 1
|
||||
|
||||
|
||||
async def test_testversand_meldet_nicht_eingerichtete_kanaele(
|
||||
auth_client: AsyncClient, smtp: type[FakeSmtp], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "apprise_urls", None)
|
||||
|
||||
body = (await auth_client.post("/api/notifications/test", json={})).json()
|
||||
|
||||
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
|
||||
assert nach_kanal["smtp"]["sent"] is True
|
||||
assert nach_kanal["apprise"]["configured"] is False
|
||||
assert nach_kanal["apprise"]["sent"] is False
|
||||
# Ein fehlender Kanal ist kein Fehler.
|
||||
assert body["any_sent"] is True
|
||||
|
||||
|
||||
async def test_einrichtungsstand_wird_gemeldet(
|
||||
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "smtp_host", None)
|
||||
monkeypatch.setattr(settings, "apprise_urls", None)
|
||||
|
||||
body = (await auth_client.get("/api/notifications/settings")).json()
|
||||
|
||||
assert body["run_at"] == "07:00"
|
||||
assert body["timezone"] == "Europe/Berlin"
|
||||
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["channels"]}
|
||||
assert nach_kanal["smtp"]["configured"] is False
|
||||
assert "SMTP_HOST" in nach_kanal["smtp"]["detail"]
|
||||
|
||||
|
||||
# --- Duplikatsschutz ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_zweiter_lauf_am_selben_tag_sendet_nicht_erneut(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
"""Akzeptanzkriterium: kein Doppelversand beim zweiten Lauf."""
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
erster = await service.run_all(session, date(2026, 3, 10))
|
||||
zweiter = await service.run_all(session, date(2026, 3, 10))
|
||||
|
||||
assert erster.sent == 1
|
||||
assert zweiter.sent == 0
|
||||
assert zweiter.skipped == 1
|
||||
assert len(smtp.versendet) == 1
|
||||
|
||||
|
||||
async def test_auch_ein_spaeterer_lauf_im_vorlauf_sendet_nicht_erneut(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
"""Der Schlüssel ist der Zieltag, nicht der Versandtag."""
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
await service.run_all(session, date(2026, 3, 10))
|
||||
await service.run_all(session, date(2026, 3, 11))
|
||||
await service.run_all(session, date(2026, 3, 12))
|
||||
|
||||
assert len(smtp.versendet) == 1
|
||||
|
||||
|
||||
async def test_naechster_monat_wird_wieder_gemeldet(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
await service.run_all(session, date(2026, 3, 10))
|
||||
await service.run_all(session, date(2026, 4, 10))
|
||||
|
||||
assert len(smtp.versendet) == 2
|
||||
|
||||
|
||||
async def test_protokoll_haelt_den_zieltag_fest(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
await service.run_all(session, date(2026, 3, 10))
|
||||
|
||||
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
|
||||
assert len(eintraege) == 1
|
||||
assert eintraege[0].status is NotificationStatus.SENT
|
||||
assert eintraege[0].dedupe_day == date(2026, 3, 15)
|
||||
assert eintraege[0].ref_type == "occurrence"
|
||||
|
||||
|
||||
async def test_fehlgeschlagener_versand_wird_erneut_versucht(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
"""Ein Fehlschlag darf nicht dazu führen, dass die Meldung verloren geht."""
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
smtp.fehler = smtplib.SMTPServerDisconnected("Server weg")
|
||||
erster = await service.run_all(session, date(2026, 3, 10))
|
||||
assert erster.sent == 0
|
||||
|
||||
eintrag = (await session.execute(select(NotificationLog))).scalar_one()
|
||||
assert eintrag.status is NotificationStatus.FAILED
|
||||
assert eintrag.error
|
||||
|
||||
smtp.fehler = None
|
||||
zweiter = await service.run_all(session, date(2026, 3, 11))
|
||||
|
||||
assert zweiter.sent == 1
|
||||
assert len(smtp.versendet) == 1
|
||||
# Der vorhandene Eintrag wird aktualisiert, kein zweiter angelegt.
|
||||
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
|
||||
assert len(eintraege) == 1
|
||||
assert eintraege[0].status is NotificationStatus.SENT
|
||||
assert eintraege[0].error is None
|
||||
|
||||
|
||||
# --- Ereignisarten --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_faelligkeiten_ausserhalb_des_vorlaufs_bleiben_still(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=3)
|
||||
|
||||
ergebnis = await service.run_all(session, date(2026, 3, 1))
|
||||
|
||||
assert ergebnis.sent == 0
|
||||
assert smtp.versendet == []
|
||||
|
||||
|
||||
async def test_bestaetigte_faelligkeit_wird_nicht_gemeldet(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
posten = await abo(
|
||||
auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15"
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-03-15"},
|
||||
)
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
ergebnis = await service.run_all(session, date(2026, 3, 10))
|
||||
|
||||
assert ergebnis.sent == 0
|
||||
|
||||
|
||||
async def test_kuendigungsfrist_meldet_sich_in_drei_stufen(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
"""30, 14 und 7 Tage vor dem Termin – je einmal."""
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
contract_start="2026-01-01",
|
||||
contract_min_term_months=24,
|
||||
contract_notice_period_days=90,
|
||||
)
|
||||
await regel(session, NotificationType.NOTICE_DEADLINE, lead_days=30)
|
||||
|
||||
# Laufzeitende 31.12.2027, Frist also am 02.10.2027.
|
||||
frist = date(2027, 10, 2)
|
||||
for versatz in (30, 14, 7):
|
||||
await service.run_all(session, frist - timedelta(days=versatz))
|
||||
# Ein weiterer Lauf innerhalb derselben Stufe meldet nichts Neues.
|
||||
await service.run_all(session, frist - timedelta(days=6))
|
||||
|
||||
assert len(smtp.versendet) == 3
|
||||
assert all("Handyvertrag" in text_of(nachricht) for nachricht in smtp.versendet)
|
||||
|
||||
|
||||
async def test_gekuendigter_vertrag_meldet_keine_frist(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
posten = await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
contract_start="2026-01-01",
|
||||
contract_min_term_months=24,
|
||||
contract_notice_period_days=90,
|
||||
)
|
||||
await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")
|
||||
await regel(session, NotificationType.NOTICE_DEADLINE)
|
||||
|
||||
ergebnis = await service.run_all(session, date(2027, 9, 15))
|
||||
|
||||
assert ergebnis.sent == 0
|
||||
|
||||
|
||||
async def test_ueberschrittenes_budget_meldet_sich_einmal_im_monat(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-01",
|
||||
"limit_amount": "100.00",
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Großeinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "150.00",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
await regel(session, NotificationType.BUDGET_EXCEEDED)
|
||||
|
||||
erster = await service.run_all(session, date(2026, 3, 10))
|
||||
zweiter = await service.run_all(session, date(2026, 3, 20))
|
||||
|
||||
assert erster.sent == 1
|
||||
assert zweiter.sent == 0
|
||||
assert "Lebensmittel" in text_of(smtp.versendet[0])
|
||||
|
||||
|
||||
async def test_vertragsverlaengerung_im_kommenden_monat(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Zeitschrift",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
contract_start="2026-01-01",
|
||||
contract_min_term_months=12,
|
||||
contract_auto_renew_months=12,
|
||||
)
|
||||
await regel(session, NotificationType.CONTRACT_RENEWAL)
|
||||
|
||||
# Verlängerung am 01.01.2027; ein Lauf im Dezember trifft das Fenster.
|
||||
ergebnis = await service.run_all(session, date(2026, 12, 5))
|
||||
|
||||
assert ergebnis.sent == 1
|
||||
assert "Zeitschrift" in text_of(smtp.versendet[0])
|
||||
|
||||
|
||||
async def test_abgeschaltete_regel_laeuft_nicht(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
eintrag = await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
eintrag.is_active = False
|
||||
await session.flush()
|
||||
|
||||
ergebnis = await service.run_all(session, date(2026, 3, 10))
|
||||
|
||||
assert ergebnis.sent == 0
|
||||
assert smtp.versendet == []
|
||||
|
||||
|
||||
async def test_abgeschaltete_benachrichtigungen(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "notifications_enabled", False)
|
||||
|
||||
ergebnis = await service.run_all(session, date(2026, 3, 10))
|
||||
|
||||
assert ergebnis.checked == 0
|
||||
assert smtp.versendet == []
|
||||
|
||||
|
||||
# --- Regeln über die API --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_regeln_pflegen(auth_client: AsyncClient) -> None:
|
||||
angelegt = await auth_client.post(
|
||||
"/api/notifications/rules",
|
||||
json={"type": "due_soon", "channel": "apprise", "lead_days": 5},
|
||||
)
|
||||
assert angelegt.status_code == 201
|
||||
eintrag = angelegt.json()
|
||||
assert eintrag["lead_days"] == 5
|
||||
|
||||
geaendert = await auth_client.patch(
|
||||
f"/api/notifications/rules/{eintrag['id']}", json={"lead_days": 10, "is_active": False}
|
||||
)
|
||||
assert geaendert.json()["lead_days"] == 10
|
||||
assert geaendert.json()["is_active"] is False
|
||||
|
||||
liste = (await auth_client.get("/api/notifications/rules")).json()
|
||||
assert any(regel["id"] == eintrag["id"] for regel in liste)
|
||||
|
||||
assert (
|
||||
await auth_client.delete(f"/api/notifications/rules/{eintrag['id']}")
|
||||
).status_code == 200
|
||||
|
||||
|
||||
async def test_lauf_ueber_die_api(
|
||||
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
|
||||
) -> None:
|
||||
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
|
||||
await regel(session, NotificationType.DUE_SOON, lead_days=7)
|
||||
|
||||
antwort = await auth_client.post("/api/notifications/run", params={"as_of": "2026-03-10"})
|
||||
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.json()["sent"] == 1
|
||||
|
||||
protokoll = (await auth_client.get("/api/notifications/log")).json()
|
||||
assert len(protokoll) == 1
|
||||
assert protokoll[0]["status"] == "sent"
|
||||
|
||||
|
||||
async def test_benachrichtigungen_verlangen_anmeldung(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/notifications/rules")).status_code == 401
|
||||
assert (await client.post("/api/notifications/test", json={})).status_code == 401
|
||||
@@ -0,0 +1,214 @@
|
||||
/** Tests der Benachrichtigungs-Einstellungen. */
|
||||
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import type { NotificationRule, NotificationSettings as Einstellungen } from "@/types/api";
|
||||
|
||||
const EINSTELLUNGEN: Einstellungen = {
|
||||
enabled: true,
|
||||
scheduler_enabled: true,
|
||||
run_at: "07:00",
|
||||
timezone: "Europe/Berlin",
|
||||
next_run_at: "2026-03-11T07:00:00+01:00",
|
||||
channels: [
|
||||
{ channel: "smtp", configured: true, detail: "Einsatzbereit." },
|
||||
{ channel: "apprise", configured: false, detail: "APPRISE_URLS setzen, komma-separiert." },
|
||||
],
|
||||
};
|
||||
|
||||
const REGEL: NotificationRule = {
|
||||
id: 1,
|
||||
type: "due_soon",
|
||||
channel: "smtp",
|
||||
lead_days: 3,
|
||||
target: null,
|
||||
is_active: true,
|
||||
created_at: "2026-03-01T10:00:00+01:00",
|
||||
};
|
||||
|
||||
function mockApi(
|
||||
overrides: {
|
||||
rules?: NotificationRule[];
|
||||
/** "sent" liefert Erfolg, "failed" einen Fehler, "unconfigured" gar keinen Kanal. */
|
||||
test?: "sent" | "failed" | "unconfigured";
|
||||
} = {},
|
||||
) {
|
||||
const anfragen: { url: string; method: string; body: unknown }[] = [];
|
||||
|
||||
const json = (daten: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(daten), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
|
||||
const methode = init?.method ?? "GET";
|
||||
anfragen.push({
|
||||
url,
|
||||
method: methode,
|
||||
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
|
||||
});
|
||||
|
||||
if (url.includes("/notifications/settings")) return json(EINSTELLUNGEN);
|
||||
if (url.includes("/notifications/log")) return json([]);
|
||||
if (url.includes("/notifications/test")) {
|
||||
const modus = overrides.test ?? "sent";
|
||||
const smtp =
|
||||
modus === "sent"
|
||||
? { channel: "smtp", configured: true, sent: true, error: null }
|
||||
: modus === "failed"
|
||||
? { channel: "smtp", configured: true, sent: false, error: "Server weg" }
|
||||
: { channel: "smtp", configured: false, sent: false, error: null };
|
||||
return json({
|
||||
results: [smtp, { channel: "apprise", configured: false, sent: false, error: null }],
|
||||
any_sent: modus === "sent",
|
||||
});
|
||||
}
|
||||
if (url.includes("/notifications/run")) {
|
||||
return json({ checked: 4, sent: 2, skipped: 2, failed: 0 });
|
||||
}
|
||||
if (url.includes("/notifications/rules")) {
|
||||
if (methode === "POST") return json({ ...REGEL, id: 9 }, 201);
|
||||
return json(overrides.rules ?? [REGEL]);
|
||||
}
|
||||
return json({});
|
||||
}),
|
||||
);
|
||||
|
||||
return anfragen;
|
||||
}
|
||||
|
||||
describe("Benachrichtigungs-Einstellungen", () => {
|
||||
beforeEach(() => {
|
||||
mockApi();
|
||||
});
|
||||
|
||||
it("zeigt Zeitplan und Einrichtungsstand der Kanäle", async () => {
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Täglicher Lauf um 07:00 Uhr/)).toBeInTheDocument();
|
||||
});
|
||||
// "E-Mail" steht sowohl auf der Kanalkarte als auch als Abzeichen an der Regel.
|
||||
expect(screen.getAllByText("E-Mail").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Einsatzbereit.")).toBeInTheDocument();
|
||||
// Der nicht eingerichtete Kanal nennt die fehlende Variable.
|
||||
expect(screen.getByText(/APPRISE_URLS setzen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("listet Regeln mit Anlass, Kanal und Vorlauf", async () => {
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Bald fällig")).toBeInTheDocument());
|
||||
expect(screen.getByText("3 Tage Vorlauf")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Meldet Fälligkeiten innerhalb der Vorlaufzeit/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet einen leeren Regelsatz verständlich", async () => {
|
||||
mockApi({ rules: [] });
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Keine Regeln")).toBeInTheDocument());
|
||||
expect(screen.getByText(/verschickt moneyfy nichts/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("löst den Testversand aus", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/test"))).toBe(true);
|
||||
});
|
||||
// Der Erfolg wird sichtbar zurückgemeldet.
|
||||
expect(await screen.findByText(/Testnachricht versendet über E-Mail/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet einen fehlgeschlagenen Testversand mit Ursache", async () => {
|
||||
mockApi({ test: "failed" });
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
|
||||
|
||||
expect(await screen.findByText(/Testversand ist fehlgeschlagen/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Server weg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("weist auf fehlende Kanäle hin, statt einen Fehler zu melden", async () => {
|
||||
mockApi({ test: "unconfigured" });
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
|
||||
|
||||
expect(await screen.findByText(/Kein Kanal eingerichtet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("startet einen Lauf und meldet das Ergebnis", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /Jetzt prüfen/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/run"))).toBe(true);
|
||||
});
|
||||
expect(await screen.findByText(/2 Benachrichtigungen versendet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 geprüft, 2 bereits gemeldet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("legt eine Regel über das Formular an", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
|
||||
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "notice_deadline");
|
||||
await nutzer.selectOptions(within(dialog).getByLabelText(/Kanal/), "apprise");
|
||||
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const angelegt = anfragen.find(
|
||||
(eintrag) => eintrag.method === "POST" && eintrag.url.includes("/notifications/rules"),
|
||||
);
|
||||
expect(angelegt?.body).toMatchObject({
|
||||
type: "notice_deadline",
|
||||
channel: "apprise",
|
||||
is_active: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("blendet den Vorlauf nur bei Fälligkeiten ein", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<NotificationSettings />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
|
||||
expect(within(dialog).getByLabelText(/Vorlauf in Tagen/)).toBeInTheDocument();
|
||||
|
||||
// Für ein Budget gibt es keinen Vorlauf.
|
||||
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "budget_exceeded");
|
||||
expect(within(dialog).queryByLabelText(/Vorlauf in Tagen/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/** Einstellungen der Benachrichtigungen: Kanäle, Regeln, Testversand, Protokoll. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import {
|
||||
BellRing,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import {
|
||||
useDeleteNotificationRule,
|
||||
useNotificationLog,
|
||||
useNotificationRules,
|
||||
useNotificationSettings,
|
||||
useRunNotifications,
|
||||
useSaveNotificationRule,
|
||||
useSendTestNotification,
|
||||
} from "@/hooks/useNotifications";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { NotificationChannel, NotificationRule, NotificationType } from "@/types/api";
|
||||
|
||||
const TYP_NAMEN: Record<NotificationType, string> = {
|
||||
due_soon: "Bald fällig",
|
||||
notice_deadline: "Kündigungsfrist",
|
||||
budget_exceeded: "Budget überschritten",
|
||||
contract_renewal: "Vertragsverlängerung",
|
||||
};
|
||||
|
||||
const TYP_HINWEISE: Record<NotificationType, string> = {
|
||||
due_soon: "Meldet Fälligkeiten innerhalb der Vorlaufzeit.",
|
||||
notice_deadline: "Meldet 30, 14 und 7 Tage vor dem letzten Kündigungstermin.",
|
||||
budget_exceeded: "Meldet überschrittene Budgets, einmal je Monat und Kategorie.",
|
||||
contract_renewal: "Meldet Verträge, die sich im kommenden Monat verlängern.",
|
||||
};
|
||||
|
||||
const KANAL_NAMEN: Record<NotificationChannel, string> = {
|
||||
smtp: "E-Mail",
|
||||
apprise: "Apprise",
|
||||
};
|
||||
|
||||
export function NotificationSettings() {
|
||||
const [formularOffen, setFormularOffen] = useState(false);
|
||||
const [bearbeiten, setBearbeiten] = useState<NotificationRule | null>(null);
|
||||
const [loeschen, setLoeschen] = useState<NotificationRule | null>(null);
|
||||
|
||||
const { data: einstellungen, isLoading } = useNotificationSettings();
|
||||
const { data: regeln = [] } = useNotificationRules();
|
||||
const { data: protokoll = [] } = useNotificationLog(25);
|
||||
const entfernen = useDeleteNotificationRule();
|
||||
const testen = useSendTestNotification();
|
||||
const laufStarten = useRunNotifications();
|
||||
|
||||
if (isLoading || !einstellungen) return <Skeleton className="h-64 w-full" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<BellRing aria-hidden className="h-4 w-4" />
|
||||
Zeitplan
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{einstellungen.scheduler_enabled
|
||||
? `Täglicher Lauf um ${einstellungen.run_at} Uhr (${einstellungen.timezone}).`
|
||||
: "Der Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false)."}
|
||||
{einstellungen.next_run_at &&
|
||||
` Nächster Lauf am ${formatDate(einstellungen.next_run_at.slice(0, 10))}.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => testen.mutate(undefined)} loading={testen.isPending}>
|
||||
<Send aria-hidden className="h-4 w-4" />
|
||||
Testnachricht
|
||||
</Button>
|
||||
<Button onClick={() => laufStarten.mutate()} loading={laufStarten.isPending}>
|
||||
<Play aria-hidden className="h-4 w-4" />
|
||||
Jetzt prüfen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{einstellungen.channels.map((kanal) => (
|
||||
<li
|
||||
key={kanal.channel}
|
||||
className="flex items-start gap-2 rounded-lg border border-line bg-raised p-3"
|
||||
>
|
||||
{kanal.configured ? (
|
||||
<CheckCircle2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-positive" />
|
||||
) : (
|
||||
<XCircle aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-faint" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{KANAL_NAMEN[kanal.channel]}</p>
|
||||
<p className="text-xs text-muted">{kanal.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-ink">Regeln</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBearbeiten(null);
|
||||
setFormularOffen(true);
|
||||
}}
|
||||
>
|
||||
<Plus aria-hidden className="h-3.5 w-3.5" />
|
||||
Regel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{regeln.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BellRing}
|
||||
title="Keine Regeln"
|
||||
description="Ohne Regel verschickt moneyfy nichts. Lege fest, worüber du informiert werden willst."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-card border border-line">
|
||||
{regeln.map((regel) => (
|
||||
<li key={regel.id} className="group flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-ink">{TYP_NAMEN[regel.type]}</span>
|
||||
<Badge>{KANAL_NAMEN[regel.channel]}</Badge>
|
||||
{regel.type === "due_soon" && (
|
||||
<Badge tone="accent">{regel.lead_days} Tage Vorlauf</Badge>
|
||||
)}
|
||||
{!regel.is_active && <Badge tone="warning">Inaktiv</Badge>}
|
||||
</div>
|
||||
<p className="truncate text-xs text-faint">
|
||||
{TYP_HINWEISE[regel.type]}
|
||||
{regel.target && ` An: ${regel.target}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBearbeiten(regel);
|
||||
setFormularOffen(true);
|
||||
}}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setLoeschen(regel)}
|
||||
aria-label={`Regel ${TYP_NAMEN[regel.type]} löschen`}
|
||||
>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{protokoll.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Versandprotokoll</h2>
|
||||
<div className="overflow-x-auto rounded-card border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<caption className="sr-only">Zuletzt versendete Benachrichtigungen</caption>
|
||||
<thead className="border-b border-line text-left text-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Gesendet
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Bezug
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Zieltag
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{protokoll.map((eintrag) => (
|
||||
<tr key={eintrag.id}>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
|
||||
{formatDate(eintrag.sent_at.slice(0, 10))}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted">
|
||||
{eintrag.ref_type} {eintrag.ref_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
|
||||
{formatDate(eintrag.dedupe_day)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{eintrag.status === "sent" ? (
|
||||
<Badge tone="positive">zugestellt</Badge>
|
||||
) : (
|
||||
<Badge tone="negative" className="max-w-xs truncate">
|
||||
{eintrag.error ?? "fehlgeschlagen"}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<RuleDialog
|
||||
rule={bearbeiten}
|
||||
open={formularOffen}
|
||||
onClose={() => {
|
||||
setFormularOffen(false);
|
||||
setBearbeiten(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={loeschen !== null}
|
||||
title="Regel löschen"
|
||||
description="Die Regel wird samt ihrem Versandprotokoll entfernt."
|
||||
loading={entfernen.isPending}
|
||||
onCancel={() => setLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleDialog({
|
||||
rule,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
rule: NotificationRule | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const speichern = useSaveNotificationRule();
|
||||
const [typ, setTyp] = useState<NotificationType>("due_soon");
|
||||
const [kanal, setKanal] = useState<NotificationChannel>("smtp");
|
||||
const [vorlauf, setVorlauf] = useState(7);
|
||||
const [ziel, setZiel] = useState("");
|
||||
const [aktiv, setAktiv] = useState(true);
|
||||
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
|
||||
|
||||
if (open && initialisiert !== (rule?.id ?? null)) {
|
||||
setTyp(rule?.type ?? "due_soon");
|
||||
setKanal(rule?.channel ?? "smtp");
|
||||
setVorlauf(rule?.lead_days ?? 7);
|
||||
setZiel(rule?.target ?? "");
|
||||
setAktiv(rule?.is_active ?? true);
|
||||
setInitialisiert(rule?.id ?? null);
|
||||
}
|
||||
|
||||
function absenden(ereignis: FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
speichern.mutate(
|
||||
{
|
||||
id: rule?.id,
|
||||
daten: rule
|
||||
? { channel: kanal, lead_days: vorlauf, target: ziel.trim() || null, is_active: aktiv }
|
||||
: {
|
||||
type: typ,
|
||||
channel: kanal,
|
||||
lead_days: vorlauf,
|
||||
target: ziel.trim() || null,
|
||||
is_active: aktiv,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInitialisiert(undefined);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
title={rule ? "Regel ändern" : "Neue Regel"}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="primary" onClick={absenden} loading={speichern.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={absenden} className="space-y-3">
|
||||
<Field label="Anlass" required hint={TYP_HINWEISE[typ]}>
|
||||
{(id) => (
|
||||
<Select
|
||||
id={id}
|
||||
value={typ}
|
||||
disabled={rule !== null}
|
||||
onChange={(ereignis) => setTyp(ereignis.target.value as NotificationType)}
|
||||
>
|
||||
{(Object.keys(TYP_NAMEN) as NotificationType[]).map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{TYP_NAMEN[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Kanal" required>
|
||||
{(id) => (
|
||||
<Select
|
||||
id={id}
|
||||
value={kanal}
|
||||
onChange={(ereignis) => setKanal(ereignis.target.value as NotificationChannel)}
|
||||
>
|
||||
<option value="smtp">E-Mail</option>
|
||||
<option value="apprise">Apprise</option>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{typ === "due_soon" && (
|
||||
<Field label="Vorlauf in Tagen" hint="Wie früh vor der Fälligkeit gemeldet wird.">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={vorlauf}
|
||||
onChange={(ereignis) => setVorlauf(Number(ereignis.target.value))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Ziel"
|
||||
hint={
|
||||
kanal === "smtp"
|
||||
? "Mailadresse; leer lassen für SMTP_FROM."
|
||||
: "Apprise-URL; leer lassen für APPRISE_URLS."
|
||||
}
|
||||
>
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={ziel}
|
||||
placeholder={kanal === "smtp" ? "ich@example.org" : "ntfy://host/topic"}
|
||||
onChange={(ereignis) => setZiel(ereignis.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
label="Aktiv"
|
||||
checked={aktiv}
|
||||
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Benachrichtigungsregeln, Protokoll und Testversand. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "@/store/toast";
|
||||
import type {
|
||||
MessageResponse,
|
||||
NotificationLogEntry,
|
||||
NotificationRule,
|
||||
NotificationRuleInput,
|
||||
NotificationRunResult,
|
||||
NotificationSettings,
|
||||
TestSendResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const KANAL_NAMEN = { smtp: "E-Mail", apprise: "Apprise" } as const;
|
||||
|
||||
export function useNotificationSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["notifications", "settings"],
|
||||
queryFn: () => api.get<NotificationSettings>("/notifications/settings"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationRules() {
|
||||
return useQuery({
|
||||
queryKey: ["notifications", "rules"],
|
||||
queryFn: () => api.get<NotificationRule[]>("/notifications/rules"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationLog(limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ["notifications", "log", limit],
|
||||
queryFn: () => api.get<NotificationLogEntry[]>("/notifications/log", { limit }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveNotificationRule() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
daten,
|
||||
}: {
|
||||
id?: number;
|
||||
daten: NotificationRuleInput | Partial<NotificationRuleInput>;
|
||||
}) =>
|
||||
id
|
||||
? api.patch<NotificationRule>(`/notifications/rules/${id}`, daten)
|
||||
: api.post<NotificationRule>("/notifications/rules", daten),
|
||||
onSuccess: (_regel, variablen) => {
|
||||
void client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
toast.success(variablen.id ? "Regel gespeichert." : "Regel angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotificationRule() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/notifications/rules/${id}`),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
toast.success("Regel gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestNotification() {
|
||||
return useMutation({
|
||||
mutationFn: (target?: string) =>
|
||||
api.post<TestSendResponse>("/notifications/test", { target: target || null }),
|
||||
onSuccess: (ergebnis) => {
|
||||
const zugestellt = ergebnis.results
|
||||
.filter((eintrag) => eintrag.sent)
|
||||
.map((eintrag) => KANAL_NAMEN[eintrag.channel]);
|
||||
const gescheitert = ergebnis.results.filter(
|
||||
(eintrag) => eintrag.configured && !eintrag.sent,
|
||||
);
|
||||
|
||||
if (zugestellt.length > 0) {
|
||||
toast.success(
|
||||
`Testnachricht versendet über ${zugestellt.join(" und ")}.`,
|
||||
gescheitert.length > 0
|
||||
? `Fehlgeschlagen: ${gescheitert
|
||||
.map((eintrag) => `${KANAL_NAMEN[eintrag.channel]} (${eintrag.error})`)
|
||||
.join(", ")}`
|
||||
: undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gescheitert.length > 0) {
|
||||
toast.error(
|
||||
"Der Testversand ist fehlgeschlagen.",
|
||||
gescheitert.map((eintrag) => eintrag.error).join(" · "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.info("Kein Kanal eingerichtet.", "Trage SMTP- oder Apprise-Daten in die Umgebung ein.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunNotifications() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<NotificationRunResult>("/notifications/run"),
|
||||
onSuccess: (ergebnis) => {
|
||||
void client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
toast.success(
|
||||
ergebnis.sent > 0
|
||||
? `${ergebnis.sent} Benachrichtigung${ergebnis.sent === 1 ? "" : "en"} versendet.`
|
||||
: "Nichts zu melden.",
|
||||
`${ergebnis.checked} geprüft, ${ergebnis.skipped} bereits gemeldet.`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { type FormEvent, useState } from "react";
|
||||
|
||||
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
@@ -31,11 +32,12 @@ const KONTOARTEN: Record<AccountType, string> = {
|
||||
cash: "Bargeld",
|
||||
};
|
||||
|
||||
type Reiter = "accounts" | "categories" | "account";
|
||||
type Reiter = "accounts" | "categories" | "notifications" | "account";
|
||||
|
||||
const REITER: { id: Reiter; label: string }[] = [
|
||||
{ id: "accounts", label: "Konten" },
|
||||
{ id: "categories", label: "Kategorien" },
|
||||
{ id: "notifications", label: "Benachrichtigungen" },
|
||||
{ id: "account", label: "Konto & Darstellung" },
|
||||
];
|
||||
|
||||
@@ -67,6 +69,7 @@ export function SettingsPage() {
|
||||
|
||||
{reiter === "accounts" && <AccountsSection />}
|
||||
{reiter === "categories" && <CategoriesSection />}
|
||||
{reiter === "notifications" && <NotificationSettings />}
|
||||
{reiter === "account" && <UserSection />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,13 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
import { useToastStore } from "@/store/toast";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
// Der Toast-Store lebt global – ohne Zurücksetzen tropfen Meldungen in den nächsten Test.
|
||||
useToastStore.getState().clear();
|
||||
});
|
||||
|
||||
// jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab.
|
||||
|
||||
@@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { Toaster } from "@/components/ui/Toaster";
|
||||
|
||||
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
|
||||
export function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
@@ -27,6 +29,8 @@ export function renderWithProviders(ui: ReactElement, route = "/") {
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
{children}
|
||||
{/* Wie in der echten Anwendung – so lassen sich Rückmeldungen prüfen. */}
|
||||
<Toaster />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -529,3 +529,75 @@ export interface Dashboard {
|
||||
upcoming: CalendarEntry[];
|
||||
upcoming_deadlines: Subscription[];
|
||||
}
|
||||
|
||||
export type NotificationType =
|
||||
| "due_soon"
|
||||
| "notice_deadline"
|
||||
| "budget_exceeded"
|
||||
| "contract_renewal";
|
||||
|
||||
export type NotificationChannel = "smtp" | "apprise";
|
||||
export type NotificationStatus = "sent" | "failed";
|
||||
|
||||
export interface NotificationRule {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
channel: NotificationChannel;
|
||||
lead_days: number;
|
||||
target: string | null;
|
||||
is_active: boolean;
|
||||
created_at: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface NotificationRuleInput {
|
||||
type: NotificationType;
|
||||
channel: NotificationChannel;
|
||||
lead_days?: number;
|
||||
target?: string | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationLogEntry {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
ref_type: string;
|
||||
ref_id: string;
|
||||
dedupe_day: IsoDate;
|
||||
sent_at: IsoDateTime;
|
||||
status: NotificationStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
channel: NotificationChannel;
|
||||
configured: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
scheduler_enabled: boolean;
|
||||
run_at: string;
|
||||
timezone: string;
|
||||
next_run_at: IsoDateTime | null;
|
||||
channels: ChannelStatus[];
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
channel: NotificationChannel;
|
||||
configured: boolean;
|
||||
sent: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TestSendResponse {
|
||||
results: TestResult[];
|
||||
any_sent: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationRunResult {
|
||||
checked: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user