feat: Notruf-Eskalation per SMS/Anruf (provider-agnostischer Webhook)

Zweiter Benachrichtigungskanal neben der E-Mail: bei einem Notruf geht
zusätzlich ein JSON-POST an EMERGENCY_WEBHOOK_URL mit Kanälen (sms,call),
Kontakt-Telefonnummern und fertigen SMS-/Anruf-Texten. Den konkreten
Gateway (Twilio, seven.io, sipgate, eigener Dienst) verdrahtet der
Empfänger dahinter — kein Provider-Lock-in im Code.

- config: EMERGENCY_WEBHOOK_URL aktiviert + _TOKEN (Bearer), _CONTACT_PHONE
  (CSV/E.164), _WEBHOOK_CHANNELS (default sms,call)
- emergency.py: async httpx-POST (best effort), Telefon-Auflösung analog
  zur E-Mail (Pref emergency_phones > Default), notified = email OR webhook;
  Hinweise kanal-neutral formuliert; Response um webhook_sent + channels ergänzt
- Tests: Webhook-Versand inkl. Payload/Bearer-Header + "ohne URL kein POST"
- Doku: §10 + Env-Var-Tabelle aktualisiert (zwei Kanäle, Payload-Format)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 10:17:49 +02:00
commit 1816a26734
4 changed files with 209 additions and 38 deletions

View file

@ -94,3 +94,58 @@ def test_manual_emergency_sends_email_when_smtp_configured(monkeypatch):
assert "benachrichtigt" in data["notice"].lower()
assert captured["to"] == "kontakt@example.com"
assert "Notruf" in captured["subject"]
def test_manual_emergency_posts_webhook_when_configured(monkeypatch):
import app.safety.emergency as em
captured = {}
class FakeResp:
is_success = True
status_code = 200
class FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None, headers=None):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return FakeResp()
monkeypatch.setattr(em.httpx, "AsyncClient", FakeClient)
monkeypatch.setattr(settings, "emergency_webhook_url", "https://hook.example/notify")
monkeypatch.setattr(settings, "emergency_webhook_token", "s3cret")
monkeypatch.setattr(settings, "emergency_contact_phone", "+4915112345678")
data = client.post("/api/emergency", json={"language": "de"}).json()
assert data["webhook_sent"] is True
assert data["email_sent"] is False # kein SMTP -> Webhook trägt allein
assert "benachrichtigt" in data["notice"].lower() # ein Kanal reicht für "gesendet"-Hinweis
assert captured["url"] == "https://hook.example/notify"
assert captured["headers"]["Authorization"] == "Bearer s3cret"
payload = captured["json"]
assert payload["category"] == "manual"
assert payload["phones"] == ["+4915112345678"]
assert set(payload["channels"]) >= {"sms", "call"}
assert "sms" in payload["message"] and "call" in payload["message"]
def test_manual_emergency_no_webhook_without_url(monkeypatch):
import app.safety.emergency as em
def _boom(*a, **k): # AsyncClient darf ohne URL gar nicht erst gebaut werden
raise AssertionError("Webhook ohne URL nicht aufrufen")
monkeypatch.setattr(em.httpx, "AsyncClient", _boom)
monkeypatch.setattr(settings, "emergency_webhook_url", "")
data = client.post("/api/emergency", json={"language": "de"}).json()
assert data["webhook_sent"] is False