Compare commits

..

No commits in common. "2ccfd7f6d6fb35902adcccf4411946fa7a4a7ec6" and "e4f0ea719950d8b7f27cfe51fb2111a744388d37" have entirely different histories.

16 changed files with 72 additions and 769 deletions

View file

@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert.
6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration)
**Administration**
7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) · [7.6 Anmeldung & Admin-Zugang](#76-anmeldung--admin-zugang-link-passwort-recovery)
7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel)
8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen)
9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken)
10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation)
@ -1798,13 +1798,6 @@ 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.
@ -1872,86 +1865,6 @@ Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen
> Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb
> (`make run`) erscheinen die Audit-Zeilen im Terminal.
### 7.6 Anmeldung & Admin-Zugang (Link, Passwort, Recovery)
> 🔧 Admin
Die App-Oberfläche (`/`) erkennt Dich an einem `va_token`-Cookie. Es gibt drei Wege hinein:
1. **Persönlicher Zugangslink** (`?k=<token>`) — der Standardweg für Senioren (keine
Login-Maske); ein gültiges Cookie loggt automatisch ein.
2. **Benutzername & Passwort (1FA, ohne 2FA)** — für Betreuer/Admins (§ 7.6.1).
3. **Recovery-Wege**, falls ein Admin ausgesperrt ist (§ 7.6.3 / 7.6.4).
Ohne gültiges Cookie zeigt `/` die Seite „Persönlicher Zugang nötig" mit einem Knopf
**„Mit Benutzername & Passwort anmelden"** (→ `/login`). Senioren nutzen weiter ihren Link;
`/` leitet bewusst **nicht** automatisch zur Login-Maske um (sonst säßen Senioren ohne
Authelia-Konto fest).
#### 7.6.1 Anmeldung mit Benutzername & Passwort (1FA, ohne 2FA)
Rufe auf (oder klicke den Knopf auf der Zugangs-Seite):
```
https://voice.jamulix.de/login
```
Ablauf: Der Reverse-Proxy schickt Dich zu **Authelia** (`auth.jamulix.de`) → Du gibst
**nur Benutzername + Passwort** ein (Zugriffsregel `voice.jamulix.de → one_factor`, also
**kein 2FA**) → das Gateway erkennt Dich am `Remote-User`, setzt ein frisches
`va_token`-Cookie und leitet in die App (`/`). Gilt für **Admins und normale
Authelia-Nutzer**; die Konten liegen im Authelia-Datei-Backend
(`/etc/authelia/users_database.yml`).
- Endpunkt dahinter: `GET /api/login` (nginx-Location `/login`).
- **Jeder Login rotiert Dein Token** — ein zuvor gesetztes Cookie / ein alter Link
*dieses Nutzers* wird ungültig (ein Token pro Nutzer).
- Andere `*.jamulix.de`-Dienste bleiben bei **2FA**; nur `voice.jamulix.de` ist 1FA.
Sicherheits-Hinweis: 1FA für den Admin-Zugang ist schwächer als 2FA — bewusste Abwägung.
#### 7.6.2 Admin-Selbstanmeldung (`/admin-login`)
`https://voice.jamulix.de/admin-login` ist die auf **Admins** (`ADMIN_USERS`) beschränkte
Variante von `/login` — gleicher Ablauf, aber Nicht-Admins bekommen 403. Praktisch als
Bookmark „Admin-Login". Endpunkt: `GET /api/admin/login` (nginx-Alias `/admin-login`).
#### 7.6.3 Recovery per `ADMIN_API_KEY` (SSO-unabhängig)
Funktioniert auch, wenn Authelia gerade nicht erreichbar ist — vom Server/localhost aus.
Den Key liest Du als root aus `/etc/voice-assistant/voice-assistant.env`.
```bash
# 1) Deine user_id finden:
curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" \
| jq -r '.[] | select(.external_id=="dschlueter") | .user_id'
USER_ID=<deine user_id>
# 2) Neuen Token ausstellen (wird EINMALIG zurückgegeben):
TOKEN=$(curl -s -X POST $URL/api/admin/users/$USER_ID/token \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq -r '.token')
# 3) Daraus den Zugangslink bauen:
echo "https://voice.jamulix.de/?k=$TOKEN"
```
> Der `ADMIN_API_KEY` ist sehr mächtig (voller Admin-Zugriff). Nicht in Proxy-Logs
> geraten lassen (kein `?key=` in URLs verwenden, außer beim einmaligen Bootstrap über
> `GET /api/admin/request-headers?key=…`), und nach solchem Gebrauch rotieren.
#### 7.6.4 Härtung gegen Total-Aussperrung
Eine echte Vollsperre bräuchte: Link weg **und** Authelia-Konto weg **und**
`ADMIN_API_KEY` weg **und** kein SSH. Um auch das abzusichern:
- **Zweiter Admin:** einen weiteren Authelia-Namen in `ADMIN_USERS` aufnehmen (CSV),
damit ein einzelnes verlorenes Konto nicht alles blockiert:
```bash
# in /etc/voice-assistant/voice-assistant.env, dann Gateway neu starten:
ADMIN_USERS=dschlueter,<zweiter-admin>
```
- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in
`/etc/voice-assistant/voice-assistant.env`).
---
## 8. Gedächtnis und Erinnerungen

View file

@ -6,7 +6,7 @@ import httpx
import yaml
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.admin_llm import (
@ -16,7 +16,7 @@ from app.admin_llm import (
switch_backend,
)
from app.audit import log_admin_action
from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user
from app.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings
from app.dependencies import get_store
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
@ -66,50 +66,6 @@ def _current_user_or_none(request: Request):
return authenticate(request.headers, client_host, token)
def _issue_login_cookie(request: Request, user, action: str):
"""Mintet ein frisches Capability-Token fuer `user`, setzt es als va_token-Cookie
und leitet in die App (/). Gemeinsame Basis fuer /api/login und /api/admin/login.
Jeder Aufruf rotiert das Token (ein Token pro Nutzer)."""
result = get_store().reset_token(user.id)
if result is None:
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden.")
_, raw_token = result
log_admin_action(request, action, user_id=user.id)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(CAPABILITY_COOKIE, raw_token, httponly=True, secure=True,
samesite="lax", max_age=31536000)
return resp
@router.get("/login")
async def app_login(request: Request):
"""Anmeldung in die App mit Benutzername/Passwort (1FA) statt persoenlichem Link.
Liegt hinter Authelia (nginx: Location /login, one_factor). Erkennt den per
Remote-User weitergereichten Authelia-Nutzer (Admin ODER normaler Nutzer),
mintet das va_token-Cookie und leitet in die App. Senioren nutzen weiter ihren
Link; dieser Weg ist der Passwort-Einstieg fuer Betreuer/Admins.
"""
user = _current_user_or_none(request)
if user is None:
raise HTTPException(status_code=403, detail="Authentication required")
return _issue_login_cookie(request, user, "app_login")
@router.get("/admin/login")
async def admin_app_login(request: Request):
"""Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink.
Wie /api/login, aber zusaetzlich auf Admins beschraenkt (ADMIN_USERS). Liegt
hinter Authelia (nginx: Location /admin-login). Mintet das va_token-Cookie und
leitet in die App (/).
"""
user = _current_user_or_none(request)
if not is_admin_user(user):
raise HTTPException(status_code=403, detail="Admin privileges required")
return _issue_login_cookie(request, user, "admin_app_login")
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def create_user(payload: UserCreate):
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
@ -221,7 +177,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_cooldown_minutes",
"emergency_contacts", "emergency_phones",
)},
"location_consent": bool((u.prefs or {}).get("location_consent", False)),
"is_admin": is_admin_user(u),

View file

@ -17,8 +17,7 @@ from app.dependencies import (
)
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.runtime_config import runtime_settings
from app.safety.emergency import record_manual_emergency, emergency_cooldown_status
from app.safety.emergency import record_manual_emergency
from app.schemas import ChatRequest, EmergencyRequest
router = APIRouter()
@ -154,12 +153,4 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ
return await record_manual_emergency(
user, store, payload.language,
geo=payload.geo, geo_error=payload.geo_error, device=payload.device,
cfg=runtime_settings,
)
@router.get("/emergency/status")
async def emergency_status(user: User = Depends(require_user)):
"""Aktueller Notruf-Sperr-Status des Nutzers (für die grüne Button-Anzeige,
auch nach einem Seiten-Neuladen)."""
return emergency_cooldown_status(user, get_store(), runtime_settings)

View file

@ -206,10 +206,6 @@ class Settings(BaseSettings):
emergency_webhook_token: str = "" # optional -> Authorization: Bearer <token>
emergency_contact_phone: str = "" # Default-Telefonnummer(n), CSV, E.164 (+49…)
emergency_webhook_channels: str = "sms,call" # angefragte Kanäle (CSV)
# Sperre nach ausgelöstem Notruf: erneuter Alarm ist N Minuten blockiert.
# Verhindert, dass verängstigte/verwirrte Senioren mehrfach hintereinander
# alarmieren. 0 = keine Sperre. Im Admin-Bereich zur Laufzeit änderbar.
emergency_cooldown_minutes: int = 5
model_config = SettingsConfigDict(
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
)

View file

@ -1,5 +1,3 @@
import asyncio
from app.schemas import AudioChunk, PipelineTrace
from app.pipeline.sentence_chunker import SentenceChunker
from app.metrics import timer, metrics
@ -199,92 +197,49 @@ class Orchestrator:
parts: list[str] = []
stream_fn = getattr(self.llm, "stream", None)
# Producer/Consumer: Die satzweise TTS-Synthese läuft in einem eigenen
# Consumer-Task, damit das Token-Streaming (Text in der Bubble) nicht
# mehr satzweise auf die Synthese wartet. Ein *einzelner* Consumer
# garantiert die Reihenfolge der Audio-Chunks — harte Invariante fürs
# gapless Playback im Client; out-of-order würde das Audio zerstören.
queue: asyncio.Queue | None = asyncio.Queue() if chunker else None
consumer_task: asyncio.Task | None = None
async def _consume() -> None:
while True:
sentence = await queue.get()
try:
if sentence is None: # Sentinel: keine Sätze mehr
return
await _emit_sentence(sentence)
finally:
queue.task_done()
async def _dispatch(sentence: str) -> None:
# Satz in die Queue legen und sofort weiterlesen (kein await auf die
# Synthese). Ist der Consumer zuvor an einer Synthese-Exception
# gestorben, diese sofort hochreichen, statt weiter zu puffern.
if consumer_task is not None and consumer_task.done():
await consumer_task
await queue.put(sentence)
if queue is not None:
consumer_task = asyncio.create_task(_consume())
try:
if stream_fn is not None:
async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language):
parts.append(delta)
if on_token:
await on_token(delta)
if chunker:
for sentence in chunker.feed(delta):
await _dispatch(sentence)
else:
result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language)
parts.append(result)
if stream_fn is not None:
async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language):
parts.append(delta)
if on_token:
await on_token(result)
await on_token(delta)
if chunker:
for sentence in chunker.feed(result):
await _dispatch(sentence)
trace.semantic_response = "".join(parts)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
language=effective_language,
)
trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response,
language=effective_language,
level=self.normalize_level,
)
if text_only:
return trace, b""
for sentence in chunker.feed(delta):
await _emit_sentence(sentence)
else:
result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language)
parts.append(result)
if on_token:
await on_token(result)
if chunker:
tail = chunker.flush()
if tail:
await _dispatch(tail)
await queue.put(None) # Sentinel: Consumer beenden
await consumer_task # auf restliche Synthese warten + Exceptions propagieren
consumer_task = None
audio = b"".join(audio_parts)
else:
audio = await self.tts.synthesize(
trace.tts_ready_text, voice=voice, language=effective_language
)
finally:
# Bei Fehler/Abbruch den noch laufenden Consumer-Task abräumen,
# damit kein verwaister Task zurückbleibt.
if consumer_task is not None and not consumer_task.done():
consumer_task.cancel()
try:
await consumer_task
except (asyncio.CancelledError, Exception):
pass
for sentence in chunker.feed(result):
await _emit_sentence(sentence)
trace.semantic_response = "".join(parts)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
language=effective_language,
)
trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response,
language=effective_language,
level=self.normalize_level,
)
if text_only:
return trace, b""
if chunker:
tail = chunker.flush()
if tail:
await _emit_sentence(tail)
audio = b"".join(audio_parts)
else:
audio = await self.tts.synthesize(
trace.tts_ready_text, voice=voice, language=effective_language
)
await self._emit_to_output(audio, output)
return trace, audio

View file

@ -75,14 +75,11 @@ _NO_ACCESS_PAGE = """<!doctype html><html lang="de"><head><meta charset="utf-8">
<style>body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;color:#1e293b;
display:grid;place-items:center;min-height:100vh;margin:0;padding:1.5rem}
.box{max-width:30rem;text-align:center}h1{font-size:1.3rem;margin:0 0 .75rem}
p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}
.btn{display:inline-block;margin-top:1.25rem;background:#2563eb;color:#fff;text-decoration:none;
font-weight:600;font-size:1.05rem;padding:.8rem 1.4rem;border-radius:.75rem}</style></head>
p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}</style></head>
<body><div class="box"><div class="emoji">🔒</div>
<h1>Persönlicher Zugang nötig</h1>
<p>Senioren: bitte die App über den persönlichen Zugangslink öffnen.</p>
<p>Betreuer und Admins können sich mit Benutzername und Passwort anmelden:</p>
<a class="btn" href="/login">Mit Benutzername &amp; Passwort anmelden</a>
<p>Bitte öffne die App über deinen persönlichen Zugangslink.</p>
<p>Wenn du keinen Link hast, wende dich an deine Betreuungsperson.</p>
</div></body></html>"""

View file

@ -33,7 +33,6 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
"emergency_cooldown_minutes": ("Notruf-Sperre (Minuten)", "int", "Erneuter Alarm für N Minuten blockiert (Standard 5, 0 = aus)"),
}
_TTL = 30.0

View file

@ -270,74 +270,10 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool:
return False
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)))
except (TypeError, ValueError):
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. Die Pause ist
pro Nutzer einstellbar (sonst greift die globale Einstellung).
"""
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:
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.
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),
}
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
# 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"})
@ -366,8 +302,6 @@ 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,

View file

@ -174,9 +174,6 @@ 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
@ -213,18 +210,6 @@ 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

@ -148,9 +148,6 @@ class Store(ABC):
@abstractmethod
def list_emergency_events(self, limit: int = 50) -> list[dict]: ...
@abstractmethod
def last_manual_emergency_at(self, user_id: str) -> str | None: ...
@abstractmethod
def get_usage_for_user(self, user_id: str) -> list[dict]: ...
@ -564,17 +561,6 @@ class SQLiteStore(Store):
).fetchall()
return [dict(r) for r in rows]
def last_manual_emergency_at(self, user_id: str) -> str | None:
"""ISO-Zeitstempel des letzten vom Nutzer ausgelösten Notrufs (oder None)."""
with self._connect() as conn:
row = conn.execute(
"SELECT created_at FROM emergency_events"
" WHERE user_id = ? AND category = 'manual'"
" ORDER BY id DESC LIMIT 1",
(user_id,),
).fetchone()
return row["created_at"] if row else None
def get_usage_for_user(self, user_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(

View file

@ -368,7 +368,6 @@ 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.
@ -423,7 +422,6 @@ function loadMe() {
applyAllowedLanguages(prefs);
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
initSosCooldown(); // grünen Sperr-Zustand nach Reload wiederherstellen
return me;
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
@ -842,9 +840,6 @@ function newConversation() {
}, 2500);
}
$("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); });
// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü).
const newChatTop = $("#new-chat-top");
if (newChatTop) newChatTop.addEventListener("click", newConversation);
// ---------- Menü (⋮) verdrahten ----------
$("#menu-toggle").addEventListener("click", () =>
@ -1192,13 +1187,6 @@ 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>
@ -1249,8 +1237,7 @@ 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_cooldown_minutes"];
"emergency_contacts", "emergency_phones"];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.value = u[k] || "";
@ -2258,145 +2245,29 @@ function deviceInfo() {
return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) };
}
// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl):
// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend —
// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter.
function setSosState(state) {
if (!emergencyBtn) return;
emergencyBtn.classList.remove(
"bg-red-600", "hover:bg-red-700", "bg-green-600", "hover:bg-green-700",
"animate-pulse", "text-sm", "text-lg"
);
if (state === "calling") {
emergencyBtn.textContent = "SOS"; // pulsiert = „wird gerufen“
emergencyBtn.disabled = true;
emergencyBtn.classList.add("bg-red-600", "animate-pulse", "text-sm");
} else if (state === "done") {
emergencyBtn.textContent = "✓"; // grün + Haken + pulsiert = Alarm raus
emergencyBtn.disabled = false; // klickbar -> beruhigender Hinweis
emergencyBtn.classList.add("bg-green-600", "hover:bg-green-700", "animate-pulse", "text-lg");
} else if (state === "error") {
emergencyBtn.textContent = "!";
emergencyBtn.disabled = false;
emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-lg");
} else { // idle
emergencyBtn.textContent = "SOS";
emergencyBtn.disabled = false;
emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-sm");
}
}
// ----- Wiederauslöse-Sperre (Cooldown) -----
// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün
// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt
// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen.
let sosCooldownActive = false;
let sosAlertedAtMs = 0;
let sosCooldownTimer = null;
function enterCooldown(remainingSec, minutesAgo) {
sosCooldownActive = true;
// Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload).
sosAlertedAtMs = Date.now() - Math.max(0, minutesAgo || 0) * 60000;
setSosState("done");
if (sosCooldownTimer) clearTimeout(sosCooldownTimer);
if (remainingSec > 0) sosCooldownTimer = setTimeout(exitCooldown, remainingSec * 1000);
else exitCooldown();
}
function exitCooldown() {
sosCooldownActive = false;
sosCooldownTimer = null;
setSosState("idle");
}
function sosMinutesAgo() {
return Math.max(0, Math.floor((Date.now() - sosAlertedAtMs) / 60000));
}
async function runEmergency() {
const lang = (langSel && langSel.value) || "de";
setSosState("calling");
try {
const g = await captureGeo();
const body = { language: lang, device: deviceInfo() };
if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts };
else body.geo_error = g.error;
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (data && data.status === "cooldown") {
// Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm.
enterCooldown(data.remaining_seconds || 0, data.minutes_ago || 0);
openSosCooldown();
return;
}
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
const cd = (data && data.cooldown_seconds) || 0;
if (cd > 0) {
enterCooldown(cd, 0); // grün/pulsierend für die Sperre
} else {
setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000);
}
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
setSosState("error");
setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 4000);
}
}
// Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich.
const sosConfirm = $("#sos-confirm");
function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); }
function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); }
// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird.
const sosCooldownDlg = $("#sos-cooldown");
function openSosCooldown() {
const m = sosMinutesAgo();
const when = m < 1 ? "gerade eben" : ("vor " + m + (m === 1 ? " Minute" : " Minuten"));
const txtEl = $("#sos-cooldown-text");
if (txtEl) txtEl.textContent =
"Ich habe Deine Helfer " + when + " alarmiert. " +
"Wahrscheinlich ist Deine Hilfe schon unterwegs.";
if (sosCooldownDlg) sosCooldownDlg.classList.remove("hidden");
}
function closeSosCooldown() { if (sosCooldownDlg) sosCooldownDlg.classList.add("hidden"); }
// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch
// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu).
async function initSosCooldown() {
try {
const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } });
if (!res.ok) return;
const s = await res.json();
if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0);
} catch (e) { /* ignore */ }
}
if (emergencyBtn) {
emergencyBtn.addEventListener("click", () => {
if (sosCooldownActive) openSosCooldown(); // schon alarmiert -> nur beruhigen
else openSosConfirm(); // sonst Notruf bestätigen
emergencyBtn.addEventListener("click", async () => {
if (!confirm("Wirklich Hilfe rufen?\nIch alarmiere sofort Helfer!")) return;
const lang = (langSel && langSel.value) || "de";
emergencyBtn.disabled = true;
try {
const g = await captureGeo();
const body = { language: lang, device: deviceInfo() };
if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts };
else body.geo_error = g.error;
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
} finally {
emergencyBtn.disabled = false;
}
});
}
const sosYesBtn = $("#sos-confirm-yes");
const sosNoBtn = $("#sos-confirm-no");
if (sosYesBtn) sosYesBtn.addEventListener("click", () => { closeSosConfirm(); runEmergency(); });
if (sosNoBtn) sosNoBtn.addEventListener("click", closeSosConfirm);
const sosCooldownOkBtn = $("#sos-cooldown-ok");
if (sosCooldownOkBtn) sosCooldownOkBtn.addEventListener("click", closeSosCooldown);
// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm).
if (sosConfirm) sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); });
if (sosCooldownDlg) sosCooldownDlg.addEventListener("click", (e) => { if (e.target === sosCooldownDlg) closeSosCooldown(); });
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm();
if (sosCooldownDlg && !sosCooldownDlg.classList.contains("hidden")) closeSosCooldown();
});

View file

@ -229,7 +229,7 @@
<img src="/favicon.svg?v=1" alt="" class="w-7 h-7 shrink-0" />
<h1 class="flex-1 truncate font-semibold text-base">Voice&nbsp;Assistant</h1>
<button type="button" id="emergency-btn" title="Hilfe rufen" aria-label="Hilfe rufen"
class="shrink-0 h-8 w-8 grid place-items-center rounded-lg bg-red-600 hover:bg-red-700 text-white font-extrabold text-sm leading-none shadow-sm transition-colors disabled:opacity-100">SOS</button>
class="shrink-0 h-9 w-9 grid place-items-center rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-base">🆘</button>
<select id="lang-sel" title="Sprache"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="de">🇩🇪 DE</option>
@ -243,13 +243,6 @@
<option value="ru">🇷🇺 RU</option>
<option value="zh">🇨🇳 ZH</option>
</select>
<!-- „Neues Gespräch" in der Kopfzeile: ab 384px Viewport sichtbar, darunter
(sehr schmale Phones, z.B. iPhone SE) ausgeblendet, damit der Titel Platz
behält. Das Menü enthält „Neues Gespräch" dauerhaft als Rückfall. -->
<button id="new-chat-top" type="button" title="Neues Gespräch" aria-label="Neues Gespräch"
class="shrink-0 h-9 w-9 hidden min-[384px]:grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<button id="menu-toggle" title="Menü" aria-label="Menü"
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮</button>
</header>
@ -372,45 +365,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<!-- ═══════════════ SOS-Bestätigung (Notruf) ═══════════════ -->
<div id="sos-confirm" role="dialog" aria-modal="true" aria-labelledby="sos-confirm-title"
class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white dark:bg-slate-800 shadow-xl overflow-hidden">
<div class="bg-red-600 text-white px-5 py-4 flex items-center gap-3">
<span class="text-3xl font-extrabold tracking-wide">SOS</span>
<span id="sos-confirm-title" class="text-lg font-semibold">Hilfe rufen?</span>
</div>
<div class="px-5 py-4">
<p class="text-base text-slate-700 dark:text-slate-200">Möchten Sie wirklich Hilfe rufen?</p>
<p class="mt-1 text-sm font-semibold text-red-600 dark:text-red-400">Es werden sofort Helfer alarmiert.</p>
</div>
<div class="px-5 pb-5 grid grid-cols-1 gap-3">
<button type="button" id="sos-confirm-yes"
class="h-14 rounded-xl bg-red-600 hover:bg-red-700 text-white text-xl font-extrabold tracking-wide transition-colors">JA, HILFE RUFEN</button>
<button type="button" id="sos-confirm-no"
class="h-12 rounded-xl border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 text-lg font-semibold hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Abbrechen</button>
</div>
</div>
</div>
<!-- ═══════════════ Notruf bereits ausgelöst (Sperre) ═══════════════ -->
<div id="sos-cooldown" role="dialog" aria-modal="true" aria-labelledby="sos-cooldown-title"
class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white dark:bg-slate-800 shadow-xl overflow-hidden">
<div class="bg-green-600 text-white px-5 py-4 flex items-center gap-3">
<span class="text-3xl font-extrabold"></span>
<span id="sos-cooldown-title" class="text-lg font-semibold">Hilfe ist unterwegs</span>
</div>
<div class="px-5 py-4">
<p id="sos-cooldown-text" class="text-base text-slate-700 dark:text-slate-200"></p>
</div>
<div class="px-5 pb-5">
<button type="button" id="sos-cooldown-ok"
class="w-full h-14 rounded-xl bg-green-600 hover:bg-green-700 text-white text-xl font-extrabold transition-colors">Verstanden</button>
</div>
</div>
</div>
<script src="/app.js?v=52"></script>
<script src="/app.js?v=48"></script>
</body>
</html>

View file

@ -1,96 +0,0 @@
"""Tests fuer die Admin-Selbstanmeldung in die App (GET /api/admin/login).
Der Endpunkt liegt in Produktion hinter Authelia (nginx setzt Remote-User).
Hier wird die Forward-Auth ueber den vertrauten Proxy simuliert.
"""
import pytest
from fastapi.testclient import TestClient
import app.dependencies as deps
from app.auth import CAPABILITY_COOKIE
from app.config import settings
from app.main import app
client = TestClient(app)
HEADER = "X-Remote-User"
# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen.
TRUSTED = "testclient"
@pytest.fixture
def forward_auth(monkeypatch):
monkeypatch.setattr(settings, "trusted_auth_header", HEADER)
monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED)
monkeypatch.setattr(settings, "admin_users", "dschlueter")
monkeypatch.setattr(settings, "auth_enabled", True)
def test_admin_login_sets_cookie_and_redirects(forward_auth):
r = client.get("/api/admin/login", headers={HEADER: "dschlueter"},
follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/"
set_cookie = r.headers.get("set-cookie", "")
assert f"{CAPABILITY_COOKIE}=" in set_cookie
assert "HttpOnly" in set_cookie
def test_admin_login_cookie_token_authenticates(forward_auth):
r = client.get("/api/admin/login", headers={HEADER: "dschlueter"},
follow_redirects=False)
# Den frisch gesetzten Token aus dem Set-Cookie ziehen ...
raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0]
# ... und pruefen, dass er den Admin-Nutzer authentifiziert.
user = deps.get_store().get_user_by_token(raw)
assert user is not None
assert user.external_id == "dschlueter"
def test_admin_login_rotates_token(forward_auth):
r1 = client.get("/api/admin/login", headers={HEADER: "dschlueter"},
follow_redirects=False)
raw1 = r1.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0]
r2 = client.get("/api/admin/login", headers={HEADER: "dschlueter"},
follow_redirects=False)
raw2 = r2.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0]
assert raw1 != raw2
# Der alte Token ist nach der Rotation ungueltig.
assert deps.get_store().get_user_by_token(raw1) is None
assert deps.get_store().get_user_by_token(raw2) is not None
def test_admin_login_rejects_non_admin(forward_auth):
r = client.get("/api/admin/login", headers={HEADER: "lieschen"},
follow_redirects=False)
assert r.status_code == 403
def test_admin_login_rejects_without_identity(forward_auth):
# Vertrauter Proxy, aber kein Identitaets-Header -> keine Admin-Identitaet -> 403.
# (In Produktion faengt nginx das vorher mit dem Authelia-Redirect ab.)
r = client.get("/api/admin/login", follow_redirects=False)
assert r.status_code == 403
# --- /api/login (1FA-Passwort-Einstieg, auch fuer Nicht-Admins) ---
def test_app_login_non_admin_gets_cookie(forward_auth):
# Normaler Authelia-Nutzer (kein Admin) bekommt trotzdem ein gueltiges Cookie.
r = client.get("/api/login", headers={HEADER: "lieschen"}, follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/"
raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0]
user = deps.get_store().get_user_by_token(raw)
assert user is not None and user.external_id == "lieschen"
def test_app_login_admin_also_works(forward_auth):
r = client.get("/api/login", headers={HEADER: "dschlueter"}, follow_redirects=False)
assert r.status_code == 303
assert f"{CAPABILITY_COOKIE}=" in r.headers.get("set-cookie", "")
def test_app_login_rejects_without_identity(forward_auth):
r = client.get("/api/login", follow_redirects=False)
assert r.status_code == 403

View file

@ -1,133 +0,0 @@
"""Wiederauslöse-Sperre (Cooldown) für den manuellen Notruf.
Nach einem Alarm ist ein erneuter Alarm für emergency_cooldown_minutes blockiert
(Schutz vor Mehrfach-Alarmen durch verwirrte Senioren).
"""
import asyncio
from datetime import datetime, timezone, timedelta
import pytest
from fastapi.testclient import TestClient
import app.safety.emergency as em
from app.config import settings
from app.main import app
client = TestClient(app)
class FakeUser:
id = "u-cooldown"
display_name = "Test"
prefs: dict = {}
class FakeStore:
def __init__(self, last=None):
self._last = last
self.logged = 0
def log_emergency(self, *a, **k):
self.logged += 1
self._last = datetime.now(timezone.utc).isoformat()
def last_manual_emergency_at(self, user_id):
return self._last
def _iso_ago(seconds: int) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
def test_status_active_within_window(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(60)))
assert s["active"] is True
assert s["minutes_ago"] == 1
assert 0 < s["remaining_seconds"] <= 300
def test_status_expired(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(600)))
assert s["active"] is False
assert s["remaining_seconds"] == 0
def test_status_disabled_when_zero(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 0)
s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(1)))
assert s["active"] is False
def test_second_alarm_suppressed(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: True) # falls doch versucht
store = FakeStore(last=_iso_ago(30))
res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de"))
assert res["status"] == "cooldown"
assert res["minutes_ago"] == 0
assert store.logged == 0 # KEIN neuer Eintrag, KEIN zweiter Alarm
def test_first_alarm_goes_through(monkeypatch):
monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5)
monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: False)
monkeypatch.setattr(em.settings, "emergency_webhook_url", "")
store = FakeStore(last=None)
res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de"))
assert res["status"] == "alerted"
assert res["cooldown_seconds"] == 300
assert store.logged == 1
def test_status_endpoint(monkeypatch):
# Endpunkt-Verdrahtung: nach einem Alarm meldet /status active=True.
monkeypatch.setattr(settings, "emergency_cooldown_minutes", 5)
client.post("/api/emergency", json={"language": "de"})
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")

View file

@ -99,9 +99,6 @@ def test_record_manual_emergency_uses_profile(monkeypatch):
def log_emergency(self, *a, **k):
pass
def last_manual_emergency_at(self, user_id):
return None # kein vorheriger Notruf -> keine Sperre aktiv
res = asyncio.run(
em.record_manual_emergency(FakeUser(FULL_PREFS), DummyStore(), language="de", geo=GEO)
)

View file

@ -51,9 +51,7 @@ def test_manual_emergency_logs_and_returns_localized_notice():
assert any(e["category"] == "manual" for e in events)
def test_manual_emergency_notice_language_fallback(monkeypatch):
# Sperre aus, damit beide Auslösungen unabhängig durchgehen (nicht unterdrückt).
monkeypatch.setattr(settings, "emergency_cooldown_minutes", 0)
def test_manual_emergency_notice_language_fallback():
# Unbekannte Sprache -> deutscher Fallback-Hinweis
en = client.post("/api/emergency", json={"language": "en"}).json()["notice"]
assert "ATTENTION" in en