From 904f7db82cb56bb78b5b0921d83ed37538600755 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 17:10:12 +0200 Subject: [PATCH] feat(notruf): Alarmpause pro Nutzer im Notfall-Profil (Minuten, 0=aus, leer=global) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BEDIENUNGSANLEITUNG.md | 7 +++++ app/api/admin.py | 2 +- app/safety/emergency.py | 24 +++++++++++++---- app/schemas.py | 15 +++++++++++ app/web/app.js | 11 +++++++- app/web/index.html | 2 +- tests/test_emergency_cooldown.py | 44 ++++++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index a74b4da..8a66864 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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. diff --git a/app/api/admin.py b/app/api/admin.py index 72fec12..c501b25 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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), diff --git a/app/safety/emergency.py b/app/safety/emergency.py index ef57459..1a0d7fb 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -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: diff --git a/app/schemas.py b/app/schemas.py index ac7f96c..ec6ab5f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 (0–60)") + 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.""" diff --git a/app/web/app.js b/app/web/app.js index 9f0e6dc..93783f9 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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: 0–60 ganze Minuten" }, }; // Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt. @@ -1191,6 +1192,13 @@ function buildUserCard(u) { + +

leer = globaler Standard · 0 = aus

@@ -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] || ""; diff --git a/app/web/index.html b/app/web/index.html index 0f4e266..27510af 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -411,6 +411,6 @@ - + diff --git a/tests/test_emergency_cooldown.py b/tests/test_emergency_cooldown.py index 9d295b6..84b95ac 100644 --- a/tests/test_emergency_cooldown.py +++ b/tests/test_emergency_cooldown.py @@ -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")