feat(notruf): Wiederauslöse-Sperre + grüner Hinweis; "Neues Gespräch" in die Kopfzeile

1) Notruf-Sperre (Cooldown) gegen Mehrfach-Alarme verwirrter/verängstigter
   Senioren:
   - Neue Einstellung emergency_cooldown_minutes (Default 5), zur Laufzeit im
     Admin-Bereich änderbar (RUNTIME_SETTABLE), 0 = aus.
   - record_manual_emergency unterdrückt innerhalb der Sperre jeden weiteren
     Alarm (kein zweiter Versand, kein Log-Eintrag) und liefert status=cooldown
     mit minutes_ago/remaining_seconds. Neuer Endpunkt GET /api/emergency/status
     für die Anzeige nach einem Seiten-Neuladen. Store: last_manual_emergency_at.
   - UI: Nach dem Alarm wird der Button grün, zeigt „✓" und pulsiert für die
     Dauer der Sperre. Erneuter Druck öffnet einen grünen Hinweis-Dialog:
     „Ich habe Deine Helfer vor <n> Minuten alarmiert. Wahrscheinlich ist Deine
     Hilfe schon unterwegs." Der grüne Zustand übersteht ein Neuladen
     (Status-Abruf beim Start).

2) „Neues Gespräch" als Symbol (Stift) zurück in die Kopfzeile — zwischen
   Sprachauswahl und Menü; löst dieselbe Aktion aus wie der Menüeintrag.

Tests: neue test_emergency_cooldown.py (Status/Suppression/Endpoint); zwei
bestehende Notruf-Tests an die neue Interface-Methode bzw. die Sperre
angepasst. 250 Offline-Tests grün. Frontend via Headless-Chrome verifiziert
(Alarm->grün/pulsierend, Hinweis-Dialog, Reload-Persistenz, Header-Button).
Bump app.js v=50 -> v=51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 15:58:17 +02:00
commit 8c152ee77c
10 changed files with 290 additions and 22 deletions

View file

@ -270,10 +270,60 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool:
return False
def _cooldown_seconds(cfg) -> int:
try:
return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5)) * 60)
except (TypeError, ValueError):
return 300
def emergency_cooldown_status(user, store, cfg=settings) -> dict:
"""Aktueller Sperr-Status: ist nach dem letzten manuellen Notruf noch
eine Wiederauslöse-Sperre aktiv, wie lange ist er her, wie viel bleibt.
Schützt verwirrte/verängstigte Senioren vor Mehrfach-Alarmen.
"""
total = _cooldown_seconds(cfg)
last = store.last_manual_emergency_at(user.id) if total > 0 else None
base = {"active": False, "cooldown_seconds": total, "remaining_seconds": 0, "minutes_ago": 0}
if not last:
return base
try:
last_dt = datetime.fromisoformat(last)
except (ValueError, TypeError):
return base
elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds()
remaining = int(total - elapsed)
return {
"active": remaining > 0,
"cooldown_seconds": total,
"remaining_seconds": max(0, remaining),
"minutes_ago": max(0, int(elapsed // 60)),
}
async def record_manual_emergency(user, store, language: str | None = None,
geo=None, geo_error: str | None = None, device=None,
cfg=settings) -> dict:
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.
Innerhalb der Wiederauslöse-Sperre (emergency_cooldown_minutes) wird KEIN
neuer Alarm gesendet und kein Ereignis protokolliert stattdessen liefert
die Antwort status='cooldown' mit minutes_ago/remaining_seconds, damit das
Frontend den beruhigenden Hinweis zeigen kann.
"""
cd = emergency_cooldown_status(user, store, cfg)
if cd["active"]:
metrics.inc("emergency_total", {"category": "manual", "source": "user_suppressed"})
return {
"category": "manual",
"status": "cooldown",
"cooldown_seconds": cd["cooldown_seconds"],
"remaining_seconds": cd["remaining_seconds"],
"minutes_ago": cd["minutes_ago"],
"notice": notify_notice(language, True),
}
# 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"})
@ -302,6 +352,8 @@ async def record_manual_emergency(user, store, language: str | None = None,
notified = email_sent or webhook_sent
return {
"category": "manual",
"status": "alerted",
"cooldown_seconds": cd["cooldown_seconds"],
"notice": notify_notice(language, notified),
"email_sent": email_sent,
"webhook_sent": webhook_sent,