feat(notruf): Alarmpause pro Nutzer im Notfall-Profil (Minuten, 0=aus, leer=global)

Bisher war die Wiederauslöse-Sperre nur global einstellbar. Jetzt zusätzlich
pro Nutzer im Notfall-Profil (Admin), hinter „Standort im Notfall mitsenden":
„Pause bis zum erneuten Alarm: [n] Minuten" (leer = globaler Standard, 0 = aus).

- Auflösung serverseitig: Nutzer-Pref > globale Einstellung > 5
  (_resolve_cooldown_minutes in emergency.py; emergency_cooldown_status nutzt sie,
  die Notruf-UI/der Status-Endpunkt liefern dadurch automatisch den per-User-Wert).
- Schema AdminUserPrefsUpdate.emergency_cooldown_minutes (String) mit Validator
  (leer/0–60, sonst 422). /api/admin/users gibt das Feld jetzt mit aus
  (sonst lädt das Formular leer).
- Frontend: Zahlenfeld + Hinweis im Notfall-Profil, über PROF_KEYS/PROF_VALIDATORS
  verdrahtet. Bump app.js v=51 -> v=52.
- Doku: BEDIENUNGSANLEITUNG § Nutzer › Verwalten.

Tests: per-User-Override/Erben/Aus + Schema-Validierung. 254 Offline-Tests grün.
Live verifiziert: PUT prefs -> /status spiegelt 300->120s, 99 -> 422, Formular
zeigt/lädt das Feld hinter der Standort-Checkbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 17:10:12 +02:00
commit 904f7db82c
7 changed files with 97 additions and 8 deletions

View file

@ -1798,6 +1798,13 @@ Nutzer anlegen (Name eingeben → „Anlegen" → Token erscheint **einmalig**
umbenennen, Token zurücksetzen und löschen. Erinnerungen je Nutzer auf- und zuklappen,
neue Erinnerungen hinzufügen oder vorhandene löschen.
Im **Notfall-Profil** jedes Nutzers (aufklappbar) stehen Kontaktdaten, med. Hinweise,
„Standort im Notfall mitsenden" und **„Pause bis zum erneuten Alarm"** (Minuten): nach
einem ausgelösten Notruf ist ein erneuter Alarm so lange blockiert (Schutz vor
Mehrfach-Alarmen). **Leer** = der globale Standard greift (Tab *Konfiguration
Einstellungen* → „Notruf-Sperre (Minuten)", Default 5), **0** = aus. Präzedenz:
Nutzer-Wert > globale Einstellung > 5.
#### Nutzer Gespräche
Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen.

View file

@ -205,7 +205,7 @@ async def list_users():
**{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",
"emergency_contacts", "emergency_phones", "emergency_cooldown_minutes",
)},
"location_consent": bool((u.prefs or {}).get("location_consent", False)),
"is_admin": is_admin_user(u),

View file

@ -270,20 +270,34 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool:
return False
def _cooldown_seconds(cfg) -> int:
def _resolve_cooldown_minutes(user, cfg) -> int:
"""Alarmpause in Minuten. Präzedenz: Nutzer-Pref > globale Einstellung > 5.
Leerer/fehlender Pref-Wert erbt die globale Einstellung; "0" bedeutet aus.
"""
raw = (getattr(user, "prefs", None) or {}).get("emergency_cooldown_minutes")
if raw is not None and str(raw).strip() != "":
try:
return max(0, int(raw))
except (TypeError, ValueError):
pass
try:
return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5)) * 60)
return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5)))
except (TypeError, ValueError):
return 300
return 5
def _cooldown_seconds(user, cfg) -> int:
return _resolve_cooldown_minutes(user, cfg) * 60
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.
Schützt verwirrte/verängstigte Senioren vor Mehrfach-Alarmen. Die Pause ist
pro Nutzer einstellbar (sonst greift die globale Einstellung).
"""
total = _cooldown_seconds(cfg)
total = _cooldown_seconds(user, 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:

View file

@ -174,6 +174,9 @@ class AdminUserPrefsUpdate(BaseModel):
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?
# Alarmpause pro Nutzer (ganze Minuten, als String). Leer = globaler Standard,
# "0" = aus. Präzedenz: dieser Wert > globale Einstellung > 5.
emergency_cooldown_minutes: str | None = None
@field_validator("postal_code")
@classmethod
@ -210,6 +213,18 @@ class AdminUserPrefsUpdate(BaseModel):
raise ValueError("Kontakt-Telefon(e): ungültige Nummer")
return v
@field_validator("emergency_cooldown_minutes")
@classmethod
def _check_cooldown(cls, v):
if v is None or str(v).strip() == "":
return v # leer = globaler Standard (erben)
s = str(v).strip()
if not s.isdigit():
raise ValueError("Pause: nur ganze Minuten (060)")
if int(s) > 60:
raise ValueError("Pause: höchstens 60 Minuten")
return s
class UserAdminUpdate(BaseModel):
"""Admin schaltet Admin-Rechte für einen Nutzer ein/aus."""

View file

@ -368,6 +368,7 @@ const PROF_VALIDATORS = {
birth_date: { ok: _isGermanDate, msg: "Geburtsdatum: TT.MM.JJJJ" },
emergency_contacts: { ok: (v) => _csvAll(v, _isEmail), msg: "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" },
emergency_phones: { ok: (v) => _csvAll(v, _isPhone), msg: "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" },
emergency_cooldown_minutes: { ok: (v) => /^\d+$/.test(v) && +v <= 60, msg: "Pause: 060 ganze Minuten" },
};
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
@ -1191,6 +1192,13 @@ function buildUserCard(u) {
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
<input type="checkbox" class="prof-consent" /> Standort im Notfall mitsenden
</label>
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
Pause bis zum erneuten Alarm:
<input type="number" min="0" max="60" inputmode="numeric"
class="prof-f w-16 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
data-k="emergency_cooldown_minutes" placeholder="Standard" /> Minuten
</label>
<p class="text-[11px] text-slate-400">leer = globaler Standard · 0 = aus</p>
</div>
</div>
@ -1241,7 +1249,8 @@ function buildUserCard(u) {
// Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe.
const PROF_KEYS = ["full_name", "street", "postal_code", "city", "phone_mobile",
"phone_landline", "birth_date", "medical_notes",
"emergency_contacts", "emergency_phones"];
"emergency_contacts", "emergency_phones",
"emergency_cooldown_minutes"];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.value = u[k] || "";

View file

@ -411,6 +411,6 @@
</div>
</div>
<script src="/app.js?v=51"></script>
<script src="/app.js?v=52"></script>
</body>
</html>

View file

@ -6,6 +6,7 @@ Nach einem Alarm ist ein erneuter Alarm für emergency_cooldown_minutes blockier
import asyncio
from datetime import datetime, timezone, timedelta
import pytest
from fastapi.testclient import TestClient
import app.safety.emergency as em
@ -87,3 +88,46 @@ def test_status_endpoint(monkeypatch):
st = client.get("/api/emergency/status").json()
assert st["active"] is True
assert st["cooldown_seconds"] == 300
def _user_with(prefs):
u = FakeUser()
u.prefs = prefs
return u
def test_per_user_override(monkeypatch):
# Nutzer-Pref (2 Min) schlägt die globale Einstellung (5 Min).
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": "2"}),
FakeStore(last=_iso_ago(60)))
assert s["cooldown_seconds"] == 120
assert s["active"] is True # 60s < 120s
def test_per_user_empty_inherits_global(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": ""}),
FakeStore(last=_iso_ago(60)))
assert s["cooldown_seconds"] == 300 # globaler Standard
def test_per_user_zero_disables(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": "0"}),
FakeStore(last=_iso_ago(5)))
assert s["active"] is False
assert s["cooldown_seconds"] == 0
def test_schema_validates_cooldown():
from app.schemas import AdminUserPrefsUpdate
# gültig: leer (erben), "0" (aus), kleine Zahl
assert AdminUserPrefsUpdate(emergency_cooldown_minutes="").emergency_cooldown_minutes == ""
assert AdminUserPrefsUpdate(emergency_cooldown_minutes="0").emergency_cooldown_minutes == "0"
assert AdminUserPrefsUpdate(emergency_cooldown_minutes="10").emergency_cooldown_minutes == "10"
# ungültig: > 60 und nicht-numerisch
with pytest.raises(ValueError):
AdminUserPrefsUpdate(emergency_cooldown_minutes="61")
with pytest.raises(ValueError):
AdminUserPrefsUpdate(emergency_cooldown_minutes="abc")