pflegepilot_v3: E-Mails gebuendelt und im MeinPflegeFuchs-Design mit persoenlicher Anrede
Katalog-Aenderungsbenachrichtigungen kamen bislang als eine Einzelmail pro geaenderter Leistung - jetzt eine gebuendelte Mail pro Empfaenger und Durchlauf. Alle 7 proaktiven Mailtypen bekommen ein gemeinsames HTML-Design (app/email_templates.py) statt reinem Text, inklusive einer per Vorname personalisierten Begruessungszeile statt einer im Fliesstext untergehenden "Hallo X,"-Anrede. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54bad3e94b
commit
5f455f13ca
6 files changed
+580
-66
No files matched your search
@@ -0,0 +1,148 @@
|
||||
"""Gemeinsame HTML-Vorlage fuer alle proaktiven E-Mails (Betreiber-Feedback:
|
||||
reiner Text ohne Wiedererkennungswert). Tabellenbasiertes Layout mit
|
||||
durchgaengig Inline-Styles statt eines <style>-Blocks - E-Mail-Clients
|
||||
(v.a. Outlook Desktop, das externe/<style>-CSS groesstenteils ignoriert)
|
||||
brauchen das fuer verlaessliche Darstellung. Farbwerte an das bestehende
|
||||
App-Design angelehnt (siehe frontend/src/lib/theme.ts: buttonStyles.primary
|
||||
ist dasselbe Slate-800 wie hier).
|
||||
|
||||
Logo (seit 2026-08-01, Betreiber hat logo.png/Fav.ico bereitgestellt): im
|
||||
dunklen Header-Balken kommt die WEISSE Variante zum Einsatz
|
||||
(frontend/public/logo-white.png, per PIL aus dem Original-Logo generiert -
|
||||
das Original ist dunkles Liniengrafik-Blau auf transparentem Grund und waere
|
||||
auf dem dunklen Header unsichtbar). Als <img> mit `alt="MeinPflegeFuchs"`
|
||||
eingebunden, nicht als Data-URI - viele E-Mail-Clients (u.a. Gmail) blockieren
|
||||
oder entfernen eingebettete Base64-Bilder als Spam-Heuristik, eine ganz normal
|
||||
per HTTPS gehostete Bild-URL ist der zuverlaessigere, branchenuebliche Weg.
|
||||
Der `alt`-Text ist der Fallback, falls Bilder im Client blockiert sind -
|
||||
genau die Absicherung, die vorher die reine Text-Wortmarke bot. Kein
|
||||
Dark-Mode-Handling (bewusste Vereinfachung, E-Mail-Client-Unterstuetzung
|
||||
dafuer ist unzuverlaessig) - durchgehend helles Design mit hohem Kontrast.
|
||||
|
||||
Sicherheitsregel: JEDER Baustein escaped uebergebene Strings intern per
|
||||
esc() (html.escape) - Pflicht, da u.a. Nutzernamen, Aufgaben-Titel und
|
||||
Patientennamen echte Nutzereingaben sind. Aufrufer duerfen niemals rohe
|
||||
f-Strings direkt in HTML-Inhalt interpolieren, nur ueber diese Bausteine."""
|
||||
|
||||
import html as _html
|
||||
|
||||
from app.config import settings
|
||||
|
||||
FARBE_HEADER = "#1e293b" # slate-800, wie buttonStyles.primary im Frontend
|
||||
FARBE_TEXT = "#334155" # slate-700
|
||||
FARBE_MUTED = "#64748b" # slate-500
|
||||
FARBE_RAHMEN = "#e2e8f0" # slate-200
|
||||
FARBE_SEITE = "#f4f4f5"
|
||||
|
||||
LOGO_URL = f"{settings.web_origins[0]}/logo-white.png"
|
||||
|
||||
|
||||
def esc(text: object) -> str:
|
||||
return _html.escape(str(text))
|
||||
|
||||
|
||||
def vorname_aus(name: str | None, fallback: str) -> str:
|
||||
"""Extrahiert den Vornamen aus einem frei eingegebenen Namensfeld (z.B.
|
||||
Nutzer.name, das anders als Patient kein eigenes vorname-Feld hat) - erstes
|
||||
Wort vor dem ersten Leerzeichen, sonst der Fallback (i.d.R. der
|
||||
Benutzername). Betreiber-Feedback: eine Anrede mit vollem Namen oder gar
|
||||
keinem Namen wirkt unpersoenlich/plump - der Vorname allein liest sich
|
||||
freundlicher."""
|
||||
quelle = (name or "").strip()
|
||||
return quelle.split(" ")[0] if quelle else fallback
|
||||
|
||||
|
||||
def gruss(vorname: str) -> str:
|
||||
"""Eigene, freundliche Begruessungszeile statt eines 'Hallo X,' das im
|
||||
ersten Satz des Fliesstexts untergeht (Betreiber-Feedback: wirkte
|
||||
dadurch plump/unpersoenlich)."""
|
||||
return f'<p style="margin:0 0 16px; font-size:15px; color:{FARBE_TEXT};">Hallo {esc(vorname)}! 👋</p>'
|
||||
|
||||
|
||||
def absatz(text: str) -> str:
|
||||
return f'<p style="margin:0 0 12px;">{esc(text)}</p>'
|
||||
|
||||
|
||||
def liste(punkte: list[str]) -> str:
|
||||
items = "".join(f'<li style="margin-bottom:6px;">{esc(p)}</li>' for p in punkte)
|
||||
return f'<ul style="margin:0 0 12px; padding-left:20px;">{items}</ul>'
|
||||
|
||||
|
||||
def aenderungs_box(titel: str, zeilen: list[tuple[str, str | None, str | None]]) -> str:
|
||||
"""Eine Karte mit Vorher/Nachher-Vergleich - `zeilen` ist eine Liste aus
|
||||
(Label, Alt-Wert, Neu-Wert), z.B. fuer Katalog- oder Favoriten-Aenderungen."""
|
||||
rows = ""
|
||||
for label, alt, neu in zeilen:
|
||||
rows += (
|
||||
'<tr>'
|
||||
f'<td style="padding:4px 8px 4px 0; color:{FARBE_MUTED}; font-size:13px; white-space:nowrap; vertical-align:top;">{esc(label)}</td>'
|
||||
'<td style="padding:4px 0; font-size:13px;">'
|
||||
f'<span style="text-decoration:line-through; color:{FARBE_MUTED};">{esc(alt or "–")}</span>'
|
||||
f' → <strong>{esc(neu or "–")}</strong>'
|
||||
'</td>'
|
||||
'</tr>'
|
||||
)
|
||||
return (
|
||||
f'<div style="border:1px solid {FARBE_RAHMEN}; border-radius:8px; padding:12px 16px; margin-bottom:12px;">'
|
||||
f'<p style="margin:0 0 8px; font-weight:600; font-size:14px; color:{FARBE_TEXT};">{esc(titel)}</p>'
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0">{rows}</table>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _button(label: str, url: str) -> str:
|
||||
return (
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block; margin:4px 8px 4px 0;">'
|
||||
f'<tr><td style="background-color:{FARBE_HEADER}; border-radius:8px;">'
|
||||
f'<a href="{esc(url)}" style="display:inline-block; padding:12px 20px; color:#ffffff; '
|
||||
'text-decoration:none; font-size:14px; font-weight:600; font-family:inherit;">'
|
||||
f'{esc(label)}</a></td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def magic_link_reihe(links: list[tuple[str, str]]) -> str:
|
||||
"""Mehrere Button-Links nebeneinander (z.B. die drei Antragsstatus-
|
||||
Magic-Links "bewilligt"/"abgelehnt"/"noch keine Antwort")."""
|
||||
return '<div style="margin-top:8px;">' + "".join(_button(label, url) for label, url in links) + "</div>"
|
||||
|
||||
|
||||
def render_email(
|
||||
titel: str,
|
||||
inhalt_html: str,
|
||||
cta_label: str | None = None,
|
||||
cta_url: str | None = None,
|
||||
) -> str:
|
||||
"""Bettet einen HTML-Inhaltsblock (aus den obigen Bausteinen gebaut) in
|
||||
die gemeinsame MeinPflegeFuchs-Mail-Huelle: Header-Balken mit
|
||||
Text-Wortmarke, weisse Karte, optionaler CTA-Button, Fusszeile."""
|
||||
cta_html = _button(cta_label, cta_url) if cta_label and cta_url else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{esc(titel)}</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:{FARBE_SEITE}; font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:{FARBE_SEITE}; padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" style="max-width:560px;" cellpadding="0" cellspacing="0">
|
||||
<tr><td style="background-color:{FARBE_HEADER}; border-radius:12px 12px 0 0; padding:18px 28px;">
|
||||
<img src="{LOGO_URL}" alt="MeinPflegeFuchs" height="28" style="height:28px; width:auto; vertical-align:middle; border:0;">
|
||||
<span style="color:#ffffff; font-size:18px; font-weight:700; letter-spacing:-0.02em; vertical-align:middle; padding-left:10px;">MeinPflegeFuchs</span>
|
||||
</td></tr>
|
||||
<tr><td style="background-color:#ffffff; border:1px solid {FARBE_RAHMEN}; border-top:none; border-radius:0 0 12px 12px; padding:28px;">
|
||||
<h1 style="margin:0 0 16px; font-size:18px; color:{FARBE_TEXT};">{esc(titel)}</h1>
|
||||
<div style="font-size:14px; line-height:1.6; color:{FARBE_TEXT};">
|
||||
{inhalt_html}
|
||||
</div>
|
||||
{cta_html}
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 8px; text-align:center;">
|
||||
<span style="font-size:12px; color:{FARBE_MUTED};">Erstellt mit MeinPflegeFuchs</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -13,25 +13,55 @@ vollstaendige, aktuelle Abgrenzung.
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import EmailLogEintrag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> None:
|
||||
"""Versendet eine reine Text-E-Mail. Ist noch kein SMTP konfiguriert
|
||||
(smtp_host leer) oder schlaegt der Versand fehl, wird das nur geloggt,
|
||||
nicht als Exception nach oben gereicht - ein aufrufender Endpunkt wie
|
||||
def _protokollieren(db: Session, to: str, subject: str, typ: str, status: str, fehler: str | None = None) -> None:
|
||||
db.add(
|
||||
EmailLogEintrag(
|
||||
empfaenger=to, typ=typ, betreff=subject, status=status, fehlermeldung=fehler
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def send_email(
|
||||
db: Session, to: str, subject: str, body: str, *, typ: str = "sonstiges", html: str | None = None
|
||||
) -> None:
|
||||
"""Versendet eine E-Mail und protokolliert jeden Versuch in email_log
|
||||
(Betreiber-Feedback: Uebersicht ueber E-Mail-Volumen fuer
|
||||
Skalierungsentscheidungen). Ist noch kein SMTP konfiguriert (smtp_host
|
||||
leer) oder schlaegt der Versand fehl, wird das nur geloggt, nicht als
|
||||
Exception nach oben gereicht - ein aufrufender Endpunkt wie
|
||||
Passwort-vergessen soll unabhaengig vom E-Mail-Versand immer dieselbe
|
||||
Antwort liefern (siehe routers/auth.py), sonst liesse sich am
|
||||
Antwortverhalten erkennen, ob eine E-Mail-Adresse existiert."""
|
||||
Antwortverhalten erkennen, ob eine E-Mail-Adresse existiert. Der
|
||||
email_log-Eintrag wird nur per db.add() vorgemerkt, nicht committet -
|
||||
das bleibt Aufgabe des jeweiligen Aufrufers (kein zusaetzlicher
|
||||
Roundtrip gegenueber vorher).
|
||||
|
||||
`body` ist immer Pflicht (reiner Text) und dient bei gesetztem `html`
|
||||
als Fallback-Teil einer multipart/alternative-Mail fuer Text-only-
|
||||
Clients/Spamfilter - siehe app/email_templates.py fuer die gemeinsame
|
||||
HTML-Vorlage, aus der Aufrufer `html` bauen."""
|
||||
if not settings.smtp_host:
|
||||
logger.warning("SMTP nicht konfiguriert - E-Mail an %s wurde NICHT gesendet: %s", to, subject)
|
||||
_protokollieren(db, to, subject, typ, "uebersprungen")
|
||||
return
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
if html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
else:
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to
|
||||
@@ -54,5 +84,9 @@ def send_email(to: str, subject: str, body: str) -> None:
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.send_message(msg)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("E-Mail-Versand an %s fehlgeschlagen", to)
|
||||
_protokollieren(db, to, subject, typ, "fehlgeschlagen", str(exc))
|
||||
return
|
||||
|
||||
_protokollieren(db, to, subject, typ, "gesendet")
|
||||
@@ -26,6 +26,9 @@ import logging
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email_templates
|
||||
from app.config import settings
|
||||
from app.einstellungen import stempel_scheduler_lauf
|
||||
from app.email_util import send_email
|
||||
from app.models import KatalogSnapshot, Leistung, Nutzer, Patient
|
||||
from app.static_data.katalog import KATALOG
|
||||
@@ -44,6 +47,7 @@ def sync_katalog_und_benachrichtigen(db: Session) -> None:
|
||||
return
|
||||
try:
|
||||
_sync_und_benachrichtigen(db)
|
||||
stempel_scheduler_lauf("katalog")
|
||||
finally:
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": _ADVISORY_LOCK_ID})
|
||||
|
||||
@@ -53,6 +57,14 @@ def _relevante_werte(eintrag: dict) -> tuple[str | None, str | None, str | None]
|
||||
|
||||
|
||||
def _sync_und_benachrichtigen(db: Session) -> None:
|
||||
# Betreiber-Feedback: eine E-Mail PRO geaenderter Katalog-Zeile war eine
|
||||
# Flut (9 Mails fuer eine Aktualisierungsrunde). Deshalb werden jetzt
|
||||
# erst ALLE Aenderungen dieses Durchlaufs gesammelt und danach in einem
|
||||
# Rutsch zu je EINER Mail pro Empfaenger gebuendelt (siehe
|
||||
# _benachrichtige_betroffene_gesammelt()), statt pro Eintrag sofort zu
|
||||
# benachrichtigen.
|
||||
aenderungen: list[tuple[dict, tuple, tuple]] = []
|
||||
|
||||
for eintrag in KATALOG:
|
||||
katalog_id = eintrag["id"]
|
||||
neue_werte = _relevante_werte(eintrag)
|
||||
@@ -74,58 +86,117 @@ def _sync_und_benachrichtigen(db: Session) -> None:
|
||||
|
||||
alte_werte = (snapshot.betrag, snapshot.voraussetzung, snapshot.rechtsgrundlage)
|
||||
if alte_werte != neue_werte:
|
||||
_benachrichtige_betroffene(db, eintrag, alte_werte, neue_werte)
|
||||
aenderungen.append((eintrag, alte_werte, neue_werte))
|
||||
snapshot.betrag, snapshot.voraussetzung, snapshot.rechtsgrundlage = neue_werte
|
||||
|
||||
if aenderungen:
|
||||
_benachrichtige_betroffene_gesammelt(db, aenderungen)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _benachrichtige_betroffene(
|
||||
db: Session,
|
||||
eintrag: dict,
|
||||
alte_werte: tuple[str | None, str | None, str | None],
|
||||
neue_werte: tuple[str | None, str | None, str | None],
|
||||
def _benachrichtige_betroffene_gesammelt(
|
||||
db: Session, aenderungen: list[tuple[dict, tuple, tuple]]
|
||||
) -> None:
|
||||
leistungen = db.query(Leistung).filter(Leistung.katalog_id == eintrag["id"]).all()
|
||||
if not leistungen:
|
||||
return
|
||||
"""Baut je Empfaenger EINE Mail mit ALLEN fuer ihn relevanten
|
||||
Aenderungen dieses Durchlaufs, statt wie zuvor pro geaendertem
|
||||
KATALOG-Eintrag eine eigene Mail zu verschicken."""
|
||||
# E-Mail -> {Leistungsname -> [Patientennamen]}, ueber ALLE Aenderungen
|
||||
# dieses Durchlaufs hinweg aufgebaut (nicht mehr pro Eintrag einzeln).
|
||||
empfaenger: dict[str, dict[str, list[str]]] = {}
|
||||
# E-Mail -> Vorname fuer die Anrede - der Empfaenger kann der Nutzer
|
||||
# selbst ODER der Patient sein (beide koennen eine eigene E-Mail
|
||||
# hinterlegt haben), deshalb explizit je Empfaenger-Adresse zugeordnet
|
||||
# statt pauschal den Nutzernamen zu nehmen.
|
||||
empfaenger_vorname: dict[str, str] = {}
|
||||
werte_je_eintrag: dict[str, tuple[tuple, tuple]] = {}
|
||||
|
||||
# E-Mail -> Liste der betroffenen Patientennamen, dedupliziert - ein
|
||||
# Nutzer mit mehreren Patienten, die dieselbe Leistung haben, bekommt so
|
||||
# eine einzige Mail statt mehrerer.
|
||||
empfaenger: dict[str, list[str]] = {}
|
||||
for leistung in leistungen:
|
||||
patient: Patient = leistung.patient
|
||||
nutzer: Nutzer = patient.nutzer
|
||||
name = f"{patient.vorname} {patient.nachname}"
|
||||
for email in filter(None, {nutzer.email, patient.email}):
|
||||
namen = empfaenger.setdefault(email, [])
|
||||
if name not in namen:
|
||||
namen.append(name)
|
||||
for eintrag, alte_werte, neue_werte in aenderungen:
|
||||
werte_je_eintrag[eintrag["name"]] = (alte_werte, neue_werte)
|
||||
leistungen = db.query(Leistung).filter(Leistung.katalog_id == eintrag["id"]).all()
|
||||
for leistung in leistungen:
|
||||
patient: Patient = leistung.patient
|
||||
nutzer: Nutzer = patient.nutzer
|
||||
name = f"{patient.vorname} {patient.nachname}"
|
||||
for empf_email, empf_vorname in (
|
||||
(nutzer.email, email_templates.vorname_aus(nutzer.name, nutzer.benutzername)),
|
||||
(patient.email, patient.vorname),
|
||||
):
|
||||
if not empf_email:
|
||||
continue
|
||||
patienten_je_leistung = empfaenger.setdefault(empf_email, {})
|
||||
namen = patienten_je_leistung.setdefault(eintrag["name"], [])
|
||||
if name not in namen:
|
||||
namen.append(name)
|
||||
empfaenger_vorname.setdefault(empf_email, empf_vorname)
|
||||
|
||||
alt_betrag, alt_voraussetzung, _ = alte_werte
|
||||
neu_betrag, neu_voraussetzung, _ = neue_werte
|
||||
for email, leistungen_fuer_empfaenger in empfaenger.items():
|
||||
_sende_gesammelte_aenderungsmail(
|
||||
db, email, empfaenger_vorname[email], leistungen_fuer_empfaenger, werte_je_eintrag
|
||||
)
|
||||
|
||||
for email, namen in empfaenger.items():
|
||||
betreff = f"MeinPflegeFuchs – Änderung bei „{eintrag['name']}“"
|
||||
text_zeilen = [
|
||||
"Hallo,",
|
||||
"",
|
||||
f"für folgende von Ihnen verwaltete Person(en) ist die Leistung „{eintrag['name']}“ hinterlegt: "
|
||||
+ ", ".join(namen) + ".",
|
||||
"",
|
||||
"Diese Leistung hat sich im Leistungskatalog geändert:",
|
||||
]
|
||||
|
||||
def _sende_gesammelte_aenderungsmail(
|
||||
db: Session,
|
||||
email: str,
|
||||
vorname: str,
|
||||
leistungen_fuer_empfaenger: dict[str, list[str]],
|
||||
werte_je_eintrag: dict[str, tuple[tuple, tuple]],
|
||||
) -> None:
|
||||
anzahl = len(leistungen_fuer_empfaenger)
|
||||
erster_name = next(iter(leistungen_fuer_empfaenger))
|
||||
betreff = (
|
||||
f"MeinPflegeFuchs – Änderung bei „{erster_name}“"
|
||||
if anzahl == 1
|
||||
else f"MeinPflegeFuchs – {anzahl} Änderungen im Leistungskatalog"
|
||||
)
|
||||
|
||||
text_zeilen = [
|
||||
f"Hallo {vorname},",
|
||||
"",
|
||||
"im Leistungskatalog haben sich folgende, bei Ihnen hinterlegte Leistungen geändert:",
|
||||
"",
|
||||
]
|
||||
aenderungs_boxen = []
|
||||
|
||||
for leistung_name, patienten in leistungen_fuer_empfaenger.items():
|
||||
alte_werte, neue_werte = werte_je_eintrag[leistung_name]
|
||||
alt_betrag, alt_voraussetzung, _ = alte_werte
|
||||
neu_betrag, neu_voraussetzung, _ = neue_werte
|
||||
|
||||
text_zeilen.append(f"„{leistung_name}“ (für {', '.join(patienten)}):")
|
||||
box_zeilen: list[tuple[str, str | None, str | None]] = []
|
||||
if alt_betrag != neu_betrag:
|
||||
text_zeilen.append(f" Bisheriger Richtwert: {alt_betrag or '–'}")
|
||||
text_zeilen.append(f" Neuer Richtwert: {neu_betrag or '–'}")
|
||||
box_zeilen.append(("Betrag", alt_betrag, neu_betrag))
|
||||
if alt_voraussetzung != neu_voraussetzung:
|
||||
text_zeilen.append(f" Bisherige Voraussetzung: {alt_voraussetzung or '–'}")
|
||||
text_zeilen.append(f" Neue Voraussetzung: {neu_voraussetzung or '–'}")
|
||||
text_zeilen += [
|
||||
"",
|
||||
"Bitte prüfen Sie im Leistungskatalog in MeinPflegeFuchs, ob sich dadurch etwas für Sie ändert.",
|
||||
"",
|
||||
"Erstellt mit MeinPflegeFuchs.",
|
||||
]
|
||||
send_email(email, betreff, "\n".join(text_zeilen))
|
||||
box_zeilen.append(("Voraussetzung", alt_voraussetzung, neu_voraussetzung))
|
||||
text_zeilen.append("")
|
||||
|
||||
aenderungs_boxen.append(
|
||||
email_templates.aenderungs_box(f"{leistung_name} – für {', '.join(patienten)}", box_zeilen)
|
||||
)
|
||||
|
||||
text_zeilen += [
|
||||
"Bitte prüfen Sie im Leistungskatalog in MeinPflegeFuchs, ob sich dadurch etwas für Sie ändert.",
|
||||
"",
|
||||
"Erstellt mit MeinPflegeFuchs.",
|
||||
]
|
||||
|
||||
inhalt_html = (
|
||||
email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
"Im Leistungskatalog haben sich folgende, bei Ihnen hinterlegte Leistungen geändert:"
|
||||
)
|
||||
+ "".join(aenderungs_boxen)
|
||||
)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Änderungen im Leistungskatalog" if anzahl > 1 else f"Änderung bei „{erster_name}“",
|
||||
inhalt_html=inhalt_html,
|
||||
cta_label="Zum Leistungskatalog",
|
||||
cta_url=f"{settings.web_origins[0]}/katalog",
|
||||
)
|
||||
send_email(db, email, betreff, "\n".join(text_zeilen), typ="katalog_aenderung", html=html_body)
|
||||
@@ -26,10 +26,21 @@ import threading
|
||||
import time
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import antrag_token
|
||||
from app import email_templates
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, engine
|
||||
from app.einstellungen import stempel_scheduler_lauf
|
||||
from app.email_util import send_email
|
||||
from app.models import Aufgabe, Nutzer
|
||||
from app.models import Antrag, Aufgabe, Nutzer
|
||||
from app.static_data.antrag_fristen import (
|
||||
ANTRAG_ERINNERUNGS_TAGE,
|
||||
ANTRAG_TYP_LABEL_DE,
|
||||
ERINNERUNG_CUTOFF_TAGE,
|
||||
ERINNERUNG_WIEDERHOLUNG_TAGE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +77,15 @@ def _scheduler_thread() -> None:
|
||||
_sende_aufgaben_faelligkeits_erinnerungen()
|
||||
except Exception:
|
||||
logger.exception("Fehler im Aufgaben-Faelligkeits-Erinnerungs-Durchlauf")
|
||||
try:
|
||||
_sende_antragsstatus_erinnerungen()
|
||||
except Exception:
|
||||
logger.exception("Fehler im Antragsstatus-Erinnerungs-Durchlauf")
|
||||
try:
|
||||
_sende_widerspruch_eskalation()
|
||||
except Exception:
|
||||
logger.exception("Fehler im Widerspruchsfrist-Eskalations-Durchlauf")
|
||||
stempel_scheduler_lauf("reminder")
|
||||
time.sleep(_PRUEF_INTERVALL_SEKUNDEN)
|
||||
finally:
|
||||
lock_connection.close()
|
||||
@@ -88,14 +108,34 @@ def _sende_faellige_reminder() -> None:
|
||||
continue
|
||||
if jetzt.time() < nutzer.reminder_uhrzeit:
|
||||
continue
|
||||
vorname = email_templates.vorname_aus(nutzer.name, nutzer.benutzername)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Wie war der Tag?",
|
||||
inhalt_html=(
|
||||
email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
"Kurzer Check-in für heute: Wie war der Tag mit Ihrem Angehörigen? Schlaf, "
|
||||
"Stimmung, besondere Vorfälle oder Medikamente – ein kurzer Eintrag im "
|
||||
"Pflegetagebuch reicht schon, das dauert nur eine halbe Minute."
|
||||
)
|
||||
+ email_templates.absatz(
|
||||
"Diese Erinnerung lässt sich jederzeit in Ihren Kontoeinstellungen abschalten."
|
||||
)
|
||||
),
|
||||
cta_label="Zum Pflegetagebuch",
|
||||
cta_url=f"{settings.web_origins[0]}/dashboard",
|
||||
)
|
||||
send_email(
|
||||
db,
|
||||
nutzer.email,
|
||||
"MeinPflegeFuchs – Wie war der Tag?",
|
||||
(
|
||||
f"Hallo {nutzer.name or nutzer.benutzername},\n\n"
|
||||
"kurzer Check-in für heute: Wie war der Tag mit der von Ihnen gepflegten Person?\n"
|
||||
"Schlaf, Stimmung, besondere Vorfälle, Medikamente – tragen Sie es direkt im "
|
||||
"Pflegetagebuch in MeinPflegeFuchs ein, das dauert nur eine halbe Minute.\n\n"
|
||||
typ="tagebuch_reminder",
|
||||
html=html_body,
|
||||
body=(
|
||||
f"Hallo {vorname},\n\n"
|
||||
"kurzer Check-in für heute: Wie war der Tag mit Ihrem Angehörigen?\n"
|
||||
"Schlaf, Stimmung, besondere Vorfälle oder Medikamente – ein kurzer Eintrag im "
|
||||
"Pflegetagebuch reicht schon, das dauert nur eine halbe Minute.\n\n"
|
||||
"Diese Erinnerung lässt sich jederzeit in Ihren Kontoeinstellungen abschalten.\n\n"
|
||||
"Erstellt mit MeinPflegeFuchs."
|
||||
),
|
||||
@@ -139,27 +179,200 @@ def _sende_aufgaben_faelligkeits_erinnerungen() -> None:
|
||||
if not nutzer or not nutzer.email:
|
||||
continue
|
||||
if aufgabe.faellig_am == in_7_tagen and not aufgabe.erinnerung_7_tage_gesendet:
|
||||
_aufgabe_erinnerung_senden(nutzer, aufgabe, 7)
|
||||
_aufgabe_erinnerung_senden(db, nutzer, aufgabe, 7)
|
||||
aufgabe.erinnerung_7_tage_gesendet = True
|
||||
elif aufgabe.faellig_am == in_1_tag and not aufgabe.erinnerung_1_tag_gesendet:
|
||||
_aufgabe_erinnerung_senden(nutzer, aufgabe, 1)
|
||||
_aufgabe_erinnerung_senden(db, nutzer, aufgabe, 1)
|
||||
aufgabe.erinnerung_1_tag_gesendet = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _aufgabe_erinnerung_senden(nutzer: Nutzer, aufgabe: Aufgabe, tage: int) -> None:
|
||||
def _aufgabe_erinnerung_senden(db: Session, nutzer: Nutzer, aufgabe: Aufgabe, tage: int) -> None:
|
||||
frist_text = "morgen" if tage == 1 else f"in {tage} Tagen"
|
||||
vorname = email_templates.vorname_aus(nutzer.name, nutzer.benutzername)
|
||||
hinweis_zeilen = [f"Nicht vergessen: die Aufgabe „{aufgabe.titel}“ ist {frist_text} fällig."]
|
||||
if aufgabe.faellig_am:
|
||||
hinweis_zeilen.append(f"Fällig am {aufgabe.faellig_am.strftime('%d.%m.%Y')}.")
|
||||
html_body = email_templates.render_email(
|
||||
titel=f"Aufgabe fällig {frist_text}",
|
||||
inhalt_html=email_templates.gruss(vorname) + email_templates.absatz(" ".join(hinweis_zeilen)),
|
||||
cta_label="Zu den Aufgaben",
|
||||
cta_url=f"{settings.web_origins[0]}/aufgaben",
|
||||
)
|
||||
send_email(
|
||||
db,
|
||||
nutzer.email,
|
||||
f"MeinPflegeFuchs – Aufgabe fällig {frist_text}",
|
||||
(
|
||||
f"Hallo {nutzer.name or nutzer.benutzername},\n\n"
|
||||
f"folgende Aufgabe ist {frist_text} fällig:\n\n"
|
||||
f" {aufgabe.titel}\n"
|
||||
+ (f" Fällig am: {aufgabe.faellig_am.strftime('%d.%m.%Y')}\n\n" if aufgabe.faellig_am else "\n")
|
||||
typ="aufgabe_faellig",
|
||||
html=html_body,
|
||||
body=(
|
||||
f"Hallo {vorname},\n\n"
|
||||
f"nicht vergessen: die Aufgabe „{aufgabe.titel}“ ist {frist_text} fällig.\n"
|
||||
+ (f"Fällig am: {aufgabe.faellig_am.strftime('%d.%m.%Y')}\n\n" if aufgabe.faellig_am else "\n")
|
||||
+ "Direkt in MeinPflegeFuchs unter Aufgaben einsehen und ggf. als erledigt markieren.\n\n"
|
||||
"Erstellt mit MeinPflegeFuchs."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _aktions_link(token: str, ergebnis: str) -> str:
|
||||
return f"{settings.web_origins[0]}/antrag-status?token={token}&ergebnis={ergebnis}"
|
||||
|
||||
|
||||
def _sende_antragsstatus_erinnerungen() -> None:
|
||||
"""T25: periodische "ist der Bescheid da?"-Mail mit drei Magic-Links
|
||||
(bewilligt/abgelehnt/noch keine Antwort). Wiederholt sich alle
|
||||
ERINNERUNG_WIEDERHOLUNG_TAGE, bis der Nutzer antwortet oder der Antrag
|
||||
aelter als ERINNERUNG_CUTOFF_TAGE ist (dann bricht die Serie ab, siehe
|
||||
static_data/antrag_fristen.py)."""
|
||||
heute = datetime.date.today()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
kandidaten = (
|
||||
db.query(Antrag)
|
||||
.filter(
|
||||
Antrag.status.in_(["offen", "eingereicht", "in_pruefung"]),
|
||||
Antrag.typ.in_(list(ANTRAG_ERINNERUNGS_TAGE.keys())),
|
||||
Antrag.naechste_statuserinnerung_am.isnot(None),
|
||||
Antrag.naechste_statuserinnerung_am <= heute,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for antrag in kandidaten:
|
||||
patient = antrag.patient
|
||||
nutzer = patient.nutzer if patient else None
|
||||
if not nutzer or not nutzer.email:
|
||||
continue
|
||||
|
||||
basis = antrag.antragsdatum or antrag.erstellt_am.date()
|
||||
if (heute - basis).days > ERINNERUNG_CUTOFF_TAGE:
|
||||
antrag.naechste_statuserinnerung_am = None
|
||||
continue
|
||||
|
||||
token = antrag_token.erstelle_token(db, antrag)
|
||||
typ_label = ANTRAG_TYP_LABEL_DE.get(antrag.typ, antrag.typ)
|
||||
patient_name = f"{patient.vorname} {patient.nachname}"
|
||||
vorname = email_templates.vorname_aus(nutzer.name, nutzer.benutzername)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Antwort zu Ihrem Antrag schon da?",
|
||||
inhalt_html=(
|
||||
email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
f"Am {basis.strftime('%d.%m.%Y')} haben Sie für {patient_name} einen Antrag "
|
||||
f"({typ_label}) gestellt – ist inzwischen eine Antwort eingetroffen? Ein Klick "
|
||||
"reicht:"
|
||||
)
|
||||
+ email_templates.magic_link_reihe(
|
||||
[
|
||||
("Ja, bewilligt", _aktions_link(token, "bewilligt")),
|
||||
("Ja, abgelehnt", _aktions_link(token, "abgelehnt")),
|
||||
("Noch keine Antwort", _aktions_link(token, "offen")),
|
||||
]
|
||||
)
|
||||
+ email_templates.absatz(
|
||||
"Sie können den Status jederzeit auch direkt in MeinPflegeFuchs eintragen."
|
||||
)
|
||||
),
|
||||
)
|
||||
send_email(
|
||||
db,
|
||||
nutzer.email,
|
||||
"MeinPflegeFuchs – Antwort zu Ihrem Antrag schon da?",
|
||||
typ="antragsstatus_erinnerung",
|
||||
html=html_body,
|
||||
body=(
|
||||
f"Hallo {vorname},\n\n"
|
||||
f"am {basis.strftime('%d.%m.%Y')} haben Sie für {patient_name} einen Antrag "
|
||||
f"({typ_label}) gestellt. Ist inzwischen eine Antwort eingetroffen?\n\n"
|
||||
f"Ja, bewilligt: {_aktions_link(token, 'bewilligt')}\n"
|
||||
f"Ja, abgelehnt: {_aktions_link(token, 'abgelehnt')}\n"
|
||||
f"Noch keine Antwort: {_aktions_link(token, 'offen')}\n\n"
|
||||
"Sie können den Status jederzeit auch direkt in MeinPflegeFuchs eintragen.\n\n"
|
||||
"Erstellt mit MeinPflegeFuchs."
|
||||
),
|
||||
)
|
||||
antrag.naechste_statuserinnerung_am = heute + datetime.timedelta(
|
||||
days=ERINNERUNG_WIEDERHOLUNG_TAGE
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _sende_widerspruch_eskalation() -> None:
|
||||
"""T25: einmalige dringlichere Erinnerung ca. 7 Tage vor Ablauf der
|
||||
Widerspruchsfrist (§ 84 SGG), falls fuer den abgelehnten Antrag noch
|
||||
kein Widerspruch erstellt wurde. Korrelation "schon Widerspruch
|
||||
eingelegt?" laeuft rein ueber Patient+Zeitpunkt (kein eigenes FK-Feld
|
||||
zwischen einem Antrag und seinem Widerspruch)."""
|
||||
heute = datetime.date.today()
|
||||
eskalations_datum = heute + datetime.timedelta(days=7)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
kandidaten = (
|
||||
db.query(Antrag)
|
||||
.filter(
|
||||
Antrag.status == "abgelehnt",
|
||||
Antrag.widerspruch_frist.isnot(None),
|
||||
Antrag.widerspruch_frist <= eskalations_datum,
|
||||
Antrag.widerspruch_eskalation_gesendet.is_(False),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for antrag in kandidaten:
|
||||
patient = antrag.patient
|
||||
nutzer = patient.nutzer if patient else None
|
||||
if not nutzer or not nutzer.email:
|
||||
continue
|
||||
|
||||
schon_widerspruch = (
|
||||
db.query(Antrag)
|
||||
.filter(
|
||||
Antrag.patient_id == antrag.patient_id,
|
||||
Antrag.typ == "widerspruch",
|
||||
Antrag.erstellt_am > antrag.erstellt_am,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if schon_widerspruch:
|
||||
antrag.widerspruch_eskalation_gesendet = True
|
||||
continue
|
||||
|
||||
token = antrag_token.erstelle_token(db, antrag)
|
||||
typ_label = ANTRAG_TYP_LABEL_DE.get(antrag.typ, antrag.typ)
|
||||
patient_name = f"{patient.vorname} {patient.nachname}"
|
||||
tage_rest = (antrag.widerspruch_frist - heute).days
|
||||
vorname = email_templates.vorname_aus(nutzer.name, nutzer.benutzername)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Widerspruchsfrist läuft bald ab",
|
||||
inhalt_html=email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
f"Für den abgelehnten Antrag ({typ_label}) von {patient_name} läuft die "
|
||||
f"Widerspruchsfrist am {antrag.widerspruch_frist.strftime('%d.%m.%Y')} ab (noch "
|
||||
f"{tage_rest} Tage, § 84 SGG). Wir wollten Sie rechtzeitig daran erinnern."
|
||||
),
|
||||
cta_label="Jetzt Widerspruch vorbereiten",
|
||||
cta_url=_aktions_link(token, "abgelehnt"),
|
||||
)
|
||||
send_email(
|
||||
db,
|
||||
nutzer.email,
|
||||
"MeinPflegeFuchs – Widerspruchsfrist läuft bald ab",
|
||||
typ="widerspruch_eskalation",
|
||||
html=html_body,
|
||||
body=(
|
||||
f"Hallo {vorname},\n\n"
|
||||
f"für den abgelehnten Antrag ({typ_label}) von {patient_name} läuft die "
|
||||
f"Widerspruchsfrist am {antrag.widerspruch_frist.strftime('%d.%m.%Y')} ab "
|
||||
f"(noch {tage_rest} Tage, § 84 SGG). Wir wollten Sie rechtzeitig daran erinnern.\n\n"
|
||||
f"Jetzt Widerspruch vorbereiten: {_aktions_link(token, 'abgelehnt')}\n\n"
|
||||
"Erstellt mit MeinPflegeFuchs."
|
||||
),
|
||||
)
|
||||
antrag.widerspruch_eskalation_gesendet = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import changelog as changelog_logik
|
||||
from app import email_templates
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, hat_bezahlten_zugang, require_login
|
||||
@@ -136,15 +137,31 @@ def passwort_vergessen(payload: PasswortVergessenRequest, request: Request, db:
|
||||
user.reset_token_laeuft_ab = datetime.now(timezone.utc) + RESET_TOKEN_GUELTIGKEIT
|
||||
db.commit()
|
||||
link = f"{settings.web_origins[0]}/passwort-zuruecksetzen?token={token}"
|
||||
vorname = email_templates.vorname_aus(user.name, user.benutzername)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Passwort zurücksetzen",
|
||||
inhalt_html=email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
"Sie möchten Ihr Passwort zurücksetzen? Klicken Sie einfach auf den Button unten "
|
||||
"(der Link ist eine Stunde gültig). Falls Sie diese Anfrage nicht gestellt haben, "
|
||||
"ignorieren Sie diese E-Mail einfach – es ändert sich nichts an Ihrem Konto."
|
||||
),
|
||||
cta_label="Passwort zurücksetzen",
|
||||
cta_url=link,
|
||||
)
|
||||
send_email(
|
||||
db,
|
||||
user.email,
|
||||
"MeinPflegeFuchs – Passwort zurücksetzen",
|
||||
f"Hallo {user.name or user.benutzername},\n\n"
|
||||
"zum Zurücksetzen Ihres Passworts klicken Sie bitte auf folgenden Link "
|
||||
f"Hallo {vorname},\n\n"
|
||||
"Sie möchten Ihr Passwort zurücksetzen? Klicken Sie einfach auf folgenden Link "
|
||||
f"(eine Stunde gültig):\n\n{link}\n\n"
|
||||
"Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren "
|
||||
"– Ihr Passwort bleibt dann unverändert.",
|
||||
"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail einfach "
|
||||
"– es ändert sich nichts an Ihrem Konto.",
|
||||
typ="passwort_reset",
|
||||
html=html_body,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,10 @@ import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email_templates
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, engine
|
||||
from app.einstellungen import stempel_scheduler_lauf
|
||||
from app.email_util import send_email
|
||||
from app.models import Nutzer, UmkreissucheFavorit
|
||||
from app.umkreissuche import OVERPASS_URL, USER_AGENT
|
||||
@@ -92,6 +95,7 @@ def _scheduler_thread() -> None:
|
||||
_pruefe_alle_favoriten()
|
||||
except Exception:
|
||||
logger.exception("Fehler im Umkreis-Favoriten-Watch-Durchlauf")
|
||||
stempel_scheduler_lauf("umkreis_favoriten")
|
||||
time.sleep(_PRUEF_INTERVALL_SEKUNDEN)
|
||||
finally:
|
||||
lock_connection.close()
|
||||
@@ -203,8 +207,9 @@ def _benachrichtige_nutzer(
|
||||
if not nutzer or not nutzer.email:
|
||||
return
|
||||
|
||||
vorname = email_templates.vorname_aus(nutzer.name, nutzer.benutzername)
|
||||
zeilen = [
|
||||
"Hallo,",
|
||||
f"Hallo {vorname},",
|
||||
"",
|
||||
"bei folgenden von Ihnen gemerkten Einrichtungen aus der Umkreissuche haben "
|
||||
"sich die Kontaktdaten geändert:",
|
||||
@@ -220,8 +225,34 @@ def _benachrichtige_nutzer(
|
||||
"",
|
||||
"Erstellt mit MeinPflegeFuchs.",
|
||||
]
|
||||
|
||||
aenderungs_boxen = [
|
||||
email_templates.aenderungs_box(
|
||||
favorit.name,
|
||||
[(_FELD_LABEL[feld], alt, neu) for feld, (alt, neu) in abweichungen.items()],
|
||||
)
|
||||
for favorit, abweichungen in eintraege
|
||||
]
|
||||
inhalt_html = (
|
||||
email_templates.gruss(vorname)
|
||||
+ email_templates.absatz(
|
||||
"Bei folgenden von Ihnen gemerkten Einrichtungen aus der Umkreissuche haben sich die "
|
||||
"Kontaktdaten geändert:"
|
||||
)
|
||||
+ "".join(aenderungs_boxen)
|
||||
)
|
||||
html_body = email_templates.render_email(
|
||||
titel="Änderung bei gemerkten Einrichtungen",
|
||||
inhalt_html=inhalt_html,
|
||||
cta_label="Zu den Favoriten",
|
||||
cta_url=f"{settings.web_origins[0]}/umkreissuche",
|
||||
)
|
||||
|
||||
send_email(
|
||||
db,
|
||||
nutzer.email,
|
||||
"MeinPflegeFuchs – Änderung bei gemerkten Einrichtungen",
|
||||
"\n".join(zeilen),
|
||||
typ="umkreis_favorit_aenderung",
|
||||
html=html_body,
|
||||
)
|
||||
Reference in new issue
Block a user