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
|
Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den
|
||||||
Vorzeichen im Text vorbehalten.
|
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
|
### Geändert
|
||||||
|
|
||||||
- `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in
|
- `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` |
|
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
|
||||||
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
|
| `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
|
## Diagramme
|
||||||
|
|
||||||
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.api.routes import (
|
|||||||
logos,
|
logos,
|
||||||
me,
|
me,
|
||||||
merchants,
|
merchants,
|
||||||
|
notifications,
|
||||||
occurrences,
|
occurrences,
|
||||||
recurrences,
|
recurrences,
|
||||||
reports,
|
reports,
|
||||||
@@ -45,5 +46,6 @@ protected.include_router(budgets.templates)
|
|||||||
protected.include_router(savings_goals.router)
|
protected.include_router(savings_goals.router)
|
||||||
protected.include_router(reports.router)
|
protected.include_router(reports.router)
|
||||||
protected.include_router(export.router)
|
protected.include_router(export.router)
|
||||||
|
protected.include_router(notifications.router)
|
||||||
|
|
||||||
api_router.include_router(protected)
|
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.config import settings
|
||||||
from app.core.errors import register_exception_handlers
|
from app.core.errors import register_exception_handlers
|
||||||
from app.db.session import SessionLocal, engine
|
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
|
from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -25,8 +26,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"""Start- und Stopplogik der Anwendung."""
|
"""Start- und Stopplogik der Anwendung."""
|
||||||
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
await _bootstrap()
|
await _bootstrap()
|
||||||
|
start_scheduler()
|
||||||
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
||||||
yield
|
yield
|
||||||
|
shutdown_scheduler()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
logger.info("moneyfy wird beendet")
|
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 { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||||
|
|
||||||
|
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||||
import { PageHeader } from "@/components/layout/AppLayout";
|
import { PageHeader } from "@/components/layout/AppLayout";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||||
@@ -31,11 +32,12 @@ const KONTOARTEN: Record<AccountType, string> = {
|
|||||||
cash: "Bargeld",
|
cash: "Bargeld",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Reiter = "accounts" | "categories" | "account";
|
type Reiter = "accounts" | "categories" | "notifications" | "account";
|
||||||
|
|
||||||
const REITER: { id: Reiter; label: string }[] = [
|
const REITER: { id: Reiter; label: string }[] = [
|
||||||
{ id: "accounts", label: "Konten" },
|
{ id: "accounts", label: "Konten" },
|
||||||
{ id: "categories", label: "Kategorien" },
|
{ id: "categories", label: "Kategorien" },
|
||||||
|
{ id: "notifications", label: "Benachrichtigungen" },
|
||||||
{ id: "account", label: "Konto & Darstellung" },
|
{ id: "account", label: "Konto & Darstellung" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{reiter === "accounts" && <AccountsSection />}
|
{reiter === "accounts" && <AccountsSection />}
|
||||||
{reiter === "categories" && <CategoriesSection />}
|
{reiter === "categories" && <CategoriesSection />}
|
||||||
|
{reiter === "notifications" && <NotificationSettings />}
|
||||||
{reiter === "account" && <UserSection />}
|
{reiter === "account" && <UserSection />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useToastStore } from "@/store/toast";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
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.
|
// 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 { render } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Toaster } from "@/components/ui/Toaster";
|
||||||
|
|
||||||
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
|
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
|
||||||
export function createTestQueryClient(): QueryClient {
|
export function createTestQueryClient(): QueryClient {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@@ -27,6 +29,8 @@ export function renderWithProviders(ui: ReactElement, route = "/") {
|
|||||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{/* Wie in der echten Anwendung – so lassen sich Rückmeldungen prüfen. */}
|
||||||
|
<Toaster />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -529,3 +529,75 @@ export interface Dashboard {
|
|||||||
upcoming: CalendarEntry[];
|
upcoming: CalendarEntry[];
|
||||||
upcoming_deadlines: Subscription[];
|
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