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:
Dieter Schlüter 2026-06-25 15:33:58 +02:00
commit 5ed4a386bb
5 changed files with 265 additions and 30 deletions

View 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"]