feat: Notruf mit Kontaktdaten + Geoposition anreichern (Backend, Phase 1+3)
Notruf-Meldungen tragen jetzt das Notfall-Profil der auslösenden Person und —
falls vom Gerät mitgeschickt — die Geoposition. Kanal-Rollen:
- E-Mail: Voll-Dossier (Name, Adresse, Telefone, Geburtsdatum, med. Hinweise,
Koordinaten + Karten-Link + Genauigkeit + Zeit; sonst "nicht verfügbar")
- SMS: kompakt, Name + Adresse + Karten-Link
- Anruf (TTS): Name + Wohnadresse gesprochen, KEINE URL/Koordinaten, Verweis
auf SMS/E-Mail
- schemas: GeoFix + EmergencyRequest.geo; AdminUserPrefsUpdate um Profilfelder
erweitert (full_name, Adresse, Telefone, birth_date, medical_notes,
emergency_contacts/_phones, location_consent) — in user.prefs (keine Migration)
- api/chat: geo an record_manual_emergency durchgereicht
- api/admin: list_users gibt Profilfelder mit zurück (für die spätere Admin-UI)
- emergency.py: _profile/_geo_fields/_maps_link/_location_block/_email_body,
kanal-spezifische _emergency_texts; record_manual_emergency(..., geo=...)
- Tests: 9 neue (Profil-Injektion, SMS/Call/E-Mail-Texte, Geo→Map-Link,
ohne-Geo-Fallback, Payload). Suite 237 grün.
Frontend (Phase 2: Browser-Geolocation beim 🆘 + Admin-Profil-UI) steht noch aus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ea0e3674ad
commit
5ed4a386bb
5 changed files with 265 additions and 30 deletions
|
|
@ -174,6 +174,12 @@ async def list_users():
|
|||
"external_id": u.external_id,
|
||||
"created_at": u.created_at,
|
||||
"allowed_languages": (u.prefs or {}).get("allowed_languages", ""),
|
||||
**{k: (u.prefs or {}).get(k, "") for k in (
|
||||
"full_name", "street", "postal_code", "city",
|
||||
"phone_landline", "phone_mobile", "birth_date", "medical_notes",
|
||||
"emergency_contacts", "emergency_phones",
|
||||
)},
|
||||
"location_consent": bool((u.prefs or {}).get("location_consent", False)),
|
||||
"is_admin": is_admin_user(u),
|
||||
}
|
||||
for u in get_store().list_users()
|
||||
|
|
|
|||
|
|
@ -150,4 +150,4 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ
|
|||
Die eigentliche Benachrichtigung von Angehörigen folgt später.
|
||||
"""
|
||||
store = get_store()
|
||||
return await record_manual_emergency(user, store, payload.language)
|
||||
return await record_manual_emergency(user, store, payload.language, geo=payload.geo)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ Benachrichtigung von Kontaktpersonen über zwei unabhängige Kanäle (best effor
|
|||
ein JSON-POST mit Kanälen, Telefonnummern und fertigen SMS-/Anruf-Texten; den
|
||||
konkreten Gateway (Twilio, seven.io, …) verdrahtet der Empfänger dahinter.
|
||||
|
||||
Die Meldungen werden mit den Profildaten der auslösenden Person (Name, Adresse,
|
||||
Telefon, med. Hinweise) und — falls vom Gerät mitgeschickt — der Geoposition
|
||||
angereichert. Kanal-Rollen: die E-Mail trägt das Voll-Dossier, die SMS den
|
||||
Karten-Link, der Anruf nennt Name + Wohnadresse gesprochen und verweist für den
|
||||
genauen Standort auf SMS/E-Mail (Koordinaten werden NICHT vorgelesen).
|
||||
|
||||
Ohne SMTP-Host und ohne Webhook-URL wird nichts versendet und der Nutzer erhält
|
||||
einen klaren Hinweis darauf.
|
||||
"""
|
||||
|
|
@ -66,6 +72,117 @@ def _csv(raw: str | None) -> list[str]:
|
|||
return [v.strip() for v in (raw or "").replace(";", ",").split(",") if v.strip()]
|
||||
|
||||
|
||||
# --- Profil & Standort -----------------------------------------------------
|
||||
|
||||
def _profile(user) -> dict:
|
||||
"""Liest die Notfall-Profildaten der Person aus user.prefs (mit Fallbacks)."""
|
||||
prefs = getattr(user, "prefs", None)
|
||||
prefs = prefs if isinstance(prefs, dict) else {}
|
||||
|
||||
def g(key: str) -> str:
|
||||
v = prefs.get(key)
|
||||
return v.strip() if isinstance(v, str) else ""
|
||||
|
||||
name = g("full_name") or getattr(user, "display_name", "") or getattr(user, "id", "?")
|
||||
street, postal, city = g("street"), g("postal_code"), g("city")
|
||||
city_line = " ".join(p for p in (postal, city) if p)
|
||||
address_short = ", ".join(p for p in (street, city_line) if p)
|
||||
return {
|
||||
"name": name,
|
||||
"street": street,
|
||||
"postal_code": postal,
|
||||
"city": city,
|
||||
"address_short": address_short,
|
||||
"phone_landline": g("phone_landline"),
|
||||
"phone_mobile": g("phone_mobile"),
|
||||
"birth_date": g("birth_date"),
|
||||
"medical_notes": g("medical_notes"),
|
||||
}
|
||||
|
||||
|
||||
def _geo_fields(geo) -> dict | None:
|
||||
"""Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}.
|
||||
|
||||
Liefert None, wenn keine brauchbaren Koordinaten vorliegen.
|
||||
"""
|
||||
if geo is None:
|
||||
return None
|
||||
if isinstance(geo, dict):
|
||||
d = geo
|
||||
else:
|
||||
d = {k: getattr(geo, k, None) for k in ("lat", "lon", "accuracy", "ts")}
|
||||
if d.get("lat") is None or d.get("lon") is None:
|
||||
return None
|
||||
return {"lat": d["lat"], "lon": d["lon"], "accuracy": d.get("accuracy"), "ts": d.get("ts")}
|
||||
|
||||
|
||||
def _maps_link(lat, lon) -> str:
|
||||
return f"https://www.google.com/maps?q={lat},{lon}"
|
||||
|
||||
|
||||
def _location_block(geo) -> str:
|
||||
"""Mehrzeiliger Standort-Block für die E-Mail (oder klarer 'nicht verfügbar'-Hinweis)."""
|
||||
g = _geo_fields(geo)
|
||||
if not g:
|
||||
return "Standort (Gerät): nicht verfügbar (kein GPS / keine Freigabe)."
|
||||
lines = [
|
||||
f"Standort (Gerät): {g['lat']:.6f}, {g['lon']:.6f}",
|
||||
f"Karte: {_maps_link(g['lat'], g['lon'])}",
|
||||
]
|
||||
if g.get("accuracy") is not None:
|
||||
lines.append(f"Genauigkeit: ca. {round(g['accuracy'])} m")
|
||||
if g.get("ts"):
|
||||
lines.append(f"Zeitpunkt des Fixes: {g['ts']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _emergency_texts(profile: dict, geo) -> dict[str, str]:
|
||||
"""Fertige Texte für SMS und Sprachanruf (vom Webhook-Empfänger genutzt)."""
|
||||
name = profile["name"]
|
||||
addr = profile["address_short"]
|
||||
g = _geo_fields(geo)
|
||||
|
||||
# SMS: kompakt, mit Karten-Link (trägt den genauen Standort)
|
||||
sms = f"NOTRUF: {name} braucht Hilfe."
|
||||
if addr:
|
||||
sms += f" Adresse: {addr}."
|
||||
if g:
|
||||
sms += f" Standort: {_maps_link(g['lat'], g['lon'])}"
|
||||
|
||||
# Anruf: gesprochen — Name + Wohnadresse, KEINE URL/Koordinaten, Verweis auf SMS/E-Mail
|
||||
call = f"Achtung. {name} hat einen Notruf ausgelöst und braucht dringend Hilfe."
|
||||
if addr:
|
||||
call += f" Wohnadresse: {addr}."
|
||||
call += " Genaue Standortdaten und weitere Details stehen in der SMS und der E-Mail."
|
||||
|
||||
return {"sms": sms, "call": call}
|
||||
|
||||
|
||||
def _email_body(profile: dict, geo) -> str:
|
||||
"""Voll-Dossier für die E-Mail: Angaben zur Person + Standort."""
|
||||
lines = [
|
||||
f"{profile['name']} hat in der Voice-Assistant-App einen Notruf ausgelöst.",
|
||||
"",
|
||||
"Bitte umgehend Kontakt aufnehmen.",
|
||||
"",
|
||||
"— Angaben zur Person —",
|
||||
]
|
||||
fields = [
|
||||
("Adresse", profile["address_short"]),
|
||||
("Mobil", profile["phone_mobile"]),
|
||||
("Festnetz", profile["phone_landline"]),
|
||||
("Geburtsdatum", profile["birth_date"]),
|
||||
("Medizinische Hinweise", profile["medical_notes"]),
|
||||
]
|
||||
info = [f"{label}: {value}" for label, value in fields if value]
|
||||
lines.extend(info or ["(keine Profilangaben hinterlegt)"])
|
||||
lines += ["", "— Standort —", _location_block(geo), "",
|
||||
"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- Versandkanäle ---------------------------------------------------------
|
||||
|
||||
def _contacts(user, cfg) -> list[str]:
|
||||
"""Kontakt-E-Mail(s): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config."""
|
||||
prefs = getattr(user, "prefs", None) or {}
|
||||
|
|
@ -102,22 +219,7 @@ def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _emergency_texts(name: str) -> dict[str, str]:
|
||||
"""Fertige Texte für die SMS- und Sprachanruf-Kanäle (vom Webhook-Empfänger genutzt)."""
|
||||
return {
|
||||
"sms": (
|
||||
f"NOTRUF: {name} hat in der Voice-Assistant-App Hilfe angefordert. "
|
||||
f"Bitte sofort Kontakt aufnehmen."
|
||||
),
|
||||
"call": (
|
||||
f"Achtung. {name} hat über die Voice Assistant App einen Notruf ausgelöst. "
|
||||
f"Bitte nehmen Sie umgehend Kontakt auf. "
|
||||
f"Ich wiederhole: {name} braucht dringend Hilfe."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _webhook_payload(user, language: str | None, phones: list[str], channels: list[str], name: str) -> dict:
|
||||
def _webhook_payload(user, language, phones, channels, profile, geo) -> dict:
|
||||
"""Provider-agnostisches Notruf-Payload für den Eskalations-Webhook."""
|
||||
return {
|
||||
"event": "emergency",
|
||||
|
|
@ -125,10 +227,11 @@ def _webhook_payload(user, language: str | None, phones: list[str], channels: li
|
|||
"source": "voice-assistant",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"language": (language or "de"),
|
||||
"user": {"id": getattr(user, "id", None), "name": name},
|
||||
"user": {"id": getattr(user, "id", None), "name": profile["name"]},
|
||||
"channels": channels,
|
||||
"phones": phones,
|
||||
"message": _emergency_texts(name),
|
||||
"geo": _geo_fields(geo),
|
||||
"message": _emergency_texts(profile, geo),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -148,21 +251,18 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
async def record_manual_emergency(user, store, language: str | None = None, cfg=settings) -> dict:
|
||||
async def record_manual_emergency(user, store, language: str | None = None, geo=None, cfg=settings) -> dict:
|
||||
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
|
||||
# Bewusst minimaler Log-Eintrag — KEINE Adresse/Koordinaten (Admin-Log bleibt unkritisch).
|
||||
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
|
||||
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
|
||||
|
||||
name = getattr(user, "display_name", None) or getattr(user, "id", "?")
|
||||
profile = _profile(user)
|
||||
|
||||
# Kanal 1: E-Mail (SMTP)
|
||||
# Kanal 1: E-Mail (SMTP) — Voll-Dossier
|
||||
to = _contacts(user, cfg)
|
||||
subject = f"🆘 Notruf von {name}"
|
||||
body = (
|
||||
f"{name} hat in der Voice-Assistant-App einen Notruf ausgelöst.\n\n"
|
||||
f"Bitte umgehend Kontakt aufnehmen.\n\n"
|
||||
f"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"
|
||||
)
|
||||
subject = f"🆘 Notruf von {profile['name']}"
|
||||
body = _email_body(profile, geo)
|
||||
email_sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg)
|
||||
if email_sent:
|
||||
metrics.inc("emergency_notify_total", {"channel": "email"})
|
||||
|
|
@ -170,7 +270,7 @@ async def record_manual_emergency(user, store, language: str | None = None, cfg=
|
|||
# Kanal 2: SMS/Anruf (provider-agnostischer Webhook)
|
||||
phones = _phones(user, cfg)
|
||||
channels = _csv(cfg.emergency_webhook_channels)
|
||||
payload = _webhook_payload(user, language, phones, channels, name)
|
||||
payload = _webhook_payload(user, language, phones, channels, profile, geo)
|
||||
webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg)
|
||||
if webhook_sent:
|
||||
metrics.inc("emergency_notify_total", {"channel": "webhook"})
|
||||
|
|
|
|||
|
|
@ -105,14 +105,35 @@ class MemoryOut(BaseModel):
|
|||
|
||||
|
||||
|
||||
class GeoFix(BaseModel):
|
||||
"""Geoposition des auslösenden Geräts (One-Shot, vom Browser erfasst)."""
|
||||
lat: float
|
||||
lon: float
|
||||
accuracy: float | None = None # geschätzte Genauigkeit in Metern
|
||||
ts: str | None = None # ISO-Zeitstempel des Fixes
|
||||
|
||||
|
||||
class EmergencyRequest(BaseModel):
|
||||
"""Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext."""
|
||||
language: str | None = None
|
||||
geo: GeoFix | None = None
|
||||
|
||||
|
||||
class AdminUserPrefsUpdate(BaseModel):
|
||||
"""Admin setzt Nutzer-Einstellungen (z. B. erlaubte Sprachen, CSV: "de,en")."""
|
||||
"""Admin setzt Nutzer-Einstellungen: erlaubte Sprachen + Notfall-Profil."""
|
||||
allowed_languages: str | None = None
|
||||
# Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein)
|
||||
full_name: str | None = None
|
||||
street: str | None = None
|
||||
postal_code: str | None = None
|
||||
city: str | None = None
|
||||
phone_landline: str | None = None
|
||||
phone_mobile: str | None = None
|
||||
birth_date: str | None = None
|
||||
medical_notes: str | None = None
|
||||
emergency_contacts: str | None = None # Kontakt-E-Mail(s), CSV
|
||||
emergency_phones: str | None = None # Kontakt-Telefon(e), CSV
|
||||
location_consent: bool | None = None # Standort im Notfall mitsenden?
|
||||
|
||||
|
||||
class UserAdminUpdate(BaseModel):
|
||||
|
|
|
|||
108
tests/test_emergency_enrichment.py
Normal file
108
tests/test_emergency_enrichment.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import asyncio
|
||||
|
||||
import app.safety.emergency as em
|
||||
|
||||
|
||||
class FakeUser:
|
||||
id = "u1"
|
||||
display_name = "Anzeigename"
|
||||
|
||||
def __init__(self, prefs):
|
||||
self.prefs = prefs
|
||||
|
||||
|
||||
FULL_PREFS = {
|
||||
"full_name": "Frieda Schlüter",
|
||||
"street": "Hauptstraße 12",
|
||||
"postal_code": "24103",
|
||||
"city": "Kiel",
|
||||
"phone_mobile": "+4915100000000",
|
||||
"phone_landline": "+4943100000",
|
||||
"birth_date": "1940-05-01",
|
||||
"medical_notes": "Diabetes, Marcumar",
|
||||
}
|
||||
GEO = {"lat": 54.3233, "lon": 10.1394, "accuracy": 12.5, "ts": "2026-06-25T12:00:00Z"}
|
||||
MAPS = "https://www.google.com/maps?q=54.3233,10.1394"
|
||||
|
||||
|
||||
def test_profile_fallback_to_display_name():
|
||||
p = em._profile(FakeUser({}))
|
||||
assert p["name"] == "Anzeigename"
|
||||
assert p["address_short"] == ""
|
||||
|
||||
|
||||
def test_profile_address_compose():
|
||||
p = em._profile(FakeUser(FULL_PREFS))
|
||||
assert p["name"] == "Frieda Schlüter"
|
||||
assert p["address_short"] == "Hauptstraße 12, 24103 Kiel"
|
||||
|
||||
|
||||
def test_sms_text_has_name_address_and_maps_link():
|
||||
p = em._profile(FakeUser(FULL_PREFS))
|
||||
sms = em._emergency_texts(p, GEO)["sms"]
|
||||
assert "Frieda Schlüter" in sms
|
||||
assert "Hauptstraße 12, 24103 Kiel" in sms
|
||||
assert MAPS in sms
|
||||
|
||||
|
||||
def test_call_text_has_no_url_or_coords():
|
||||
p = em._profile(FakeUser(FULL_PREFS))
|
||||
call = em._emergency_texts(p, GEO)["call"]
|
||||
assert "Wohnadresse: Hauptstraße 12, 24103 Kiel" in call
|
||||
assert "http" not in call and "54.3233" not in call
|
||||
assert "SMS und der E-Mail" in call
|
||||
|
||||
|
||||
def test_email_body_full_dossier_with_geo():
|
||||
p = em._profile(FakeUser(FULL_PREFS))
|
||||
body = em._email_body(p, GEO)
|
||||
assert "Medizinische Hinweise: Diabetes, Marcumar" in body
|
||||
assert "Mobil: +4915100000000" in body
|
||||
assert f"Karte: {MAPS}" in body
|
||||
assert "ca. 12 m" in body # round(12.5) -> 12
|
||||
|
||||
|
||||
def test_email_body_geo_unavailable():
|
||||
body = em._email_body(em._profile(FakeUser(FULL_PREFS)), None)
|
||||
assert "nicht verfügbar" in body
|
||||
|
||||
|
||||
def test_geo_fields_handles_dict_and_partial():
|
||||
assert em._geo_fields(None) is None
|
||||
assert em._geo_fields({"lat": 1.0}) is None # lon fehlt -> None
|
||||
g = em._geo_fields({"lat": 1.0, "lon": 2.0})
|
||||
assert g["lat"] == 1.0 and g["accuracy"] is None
|
||||
|
||||
|
||||
def test_webhook_payload_includes_geo_and_messages():
|
||||
user = FakeUser(FULL_PREFS)
|
||||
p = em._profile(user)
|
||||
payload = em._webhook_payload(user, "de", ["+49"], ["sms", "call"], p, GEO)
|
||||
assert payload["geo"]["lat"] == 54.3233
|
||||
assert MAPS in payload["message"]["sms"]
|
||||
assert payload["user"]["name"] == "Frieda Schlüter"
|
||||
|
||||
|
||||
def test_record_manual_emergency_uses_profile(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_send_email(to, subject, body, cfg):
|
||||
captured.update(to=to, subject=subject, body=body)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(em, "_send_email_sync", fake_send_email)
|
||||
monkeypatch.setattr(em.settings, "smtp_host", "mail.example.com")
|
||||
monkeypatch.setattr(em.settings, "emergency_contact_email", "kontakt@example.com")
|
||||
monkeypatch.setattr(em.settings, "emergency_webhook_url", "") # nur E-Mail isolieren
|
||||
|
||||
class DummyStore:
|
||||
def log_emergency(self, *a, **k):
|
||||
pass
|
||||
|
||||
res = asyncio.run(
|
||||
em.record_manual_emergency(FakeUser(FULL_PREFS), DummyStore(), language="de", geo=GEO)
|
||||
)
|
||||
assert res["email_sent"] is True and res["webhook_sent"] is False
|
||||
assert "Frieda Schlüter" in captured["subject"]
|
||||
assert "Diabetes" in captured["body"]
|
||||
assert MAPS in captured["body"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue