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:
adminandClaude Sonnet 5 committed 2026-08-02 09:42:01 +02:00
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>"""
+41 -7
View File
@@ -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")
+111 -40
View File
@@ -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)
+227 -14
View File
@@ -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()
+21 -4
View File
@@ -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,
)