diff --git a/app/api/admin.py b/app/api/admin.py index 68e59f0..22db3eb 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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() diff --git a/app/api/chat.py b/app/api/chat.py index 8e1c1e0..b60e76f 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -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) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 7d1fc81..1001641 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -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"}) diff --git a/app/schemas.py b/app/schemas.py index 2e53c82..852535a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/tests/test_emergency_enrichment.py b/tests/test_emergency_enrichment.py new file mode 100644 index 0000000..aa76ae6 --- /dev/null +++ b/tests/test_emergency_enrichment.py @@ -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"]