Compare commits
9 commits
e4f0ea7199
...
2ccfd7f6d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ccfd7f6d6 | |||
| 6792e1be86 | |||
| 904f7db82c | |||
| f3dbd434b0 | |||
| 8c152ee77c | |||
| 8a6f428c1b | |||
| 0ef4fe9cb8 | |||
| b4111c887e | |||
| 1b1e1bb1d5 |
16 changed files with 769 additions and 72 deletions
|
|
@ -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. [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)
|
||||
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,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.
|
||||
|
|
@ -1865,6 +1872,86 @@ 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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import httpx
|
|||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
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 is_admin_user, require_admin, require_admin_or_user
|
||||
from app.auth import CAPABILITY_COOKIE, 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,6 +66,50 @@ 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."""
|
||||
|
|
@ -177,7 +221,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),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ from app.dependencies import (
|
|||
)
|
||||
from app.core.memory_extractor import maybe_schedule_extraction
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||
from app.safety.emergency import record_manual_emergency
|
||||
from app.runtime_config import runtime_settings
|
||||
from app.safety.emergency import record_manual_emergency, emergency_cooldown_status
|
||||
from app.schemas import ChatRequest, EmergencyRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -153,4 +154,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@ 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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import asyncio
|
||||
|
||||
from app.schemas import AudioChunk, PipelineTrace
|
||||
from app.pipeline.sentence_chunker import SentenceChunker
|
||||
from app.metrics import timer, metrics
|
||||
|
|
@ -197,49 +199,92 @@ class Orchestrator:
|
|||
|
||||
parts: list[str] = []
|
||||
stream_fn = getattr(self.llm, "stream", None)
|
||||
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 _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:
|
||||
for sentence in chunker.feed(result):
|
||||
|
||||
# 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()
|
||||
|
||||
trace.semantic_response = "".join(parts)
|
||||
if not trace.semantic_response:
|
||||
raise RuntimeError("LLM returned an empty response")
|
||||
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)
|
||||
|
||||
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 queue is not None:
|
||||
consumer_task = asyncio.create_task(_consume())
|
||||
|
||||
if text_only:
|
||||
return trace, b""
|
||||
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 on_token:
|
||||
await on_token(result)
|
||||
if chunker:
|
||||
for sentence in chunker.feed(result):
|
||||
await _dispatch(sentence)
|
||||
|
||||
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
|
||||
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 _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
|
||||
|
||||
await self._emit_to_output(audio, output)
|
||||
return trace, audio
|
||||
|
|
|
|||
|
|
@ -75,11 +75,14 @@ _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}</style></head>
|
||||
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>
|
||||
<body><div class="box"><div class="emoji">🔒</div>
|
||||
<h1>Persönlicher Zugang nötig</h1>
|
||||
<p>Bitte öffne die App über deinen persönlichen Zugangslink.</p>
|
||||
<p>Wenn du keinen Link hast, wende dich an deine Betreuungsperson.</p>
|
||||
<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 & Passwort anmelden</a>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -270,10 +270,74 @@ 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."""
|
||||
"""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 +366,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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
14
app/store.py
14
app/store.py
|
|
@ -148,6 +148,9 @@ 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]: ...
|
||||
|
||||
|
|
@ -561,6 +564,17 @@ 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(
|
||||
|
|
|
|||
175
app/web/app.js
175
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.
|
||||
|
|
@ -422,6 +423,7 @@ 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";
|
||||
|
|
@ -840,6 +842,9 @@ 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", () =>
|
||||
|
|
@ -1187,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>
|
||||
|
||||
|
|
@ -1237,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] || "";
|
||||
|
|
@ -2245,29 +2258,145 @@ function deviceInfo() {
|
|||
return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) };
|
||||
}
|
||||
|
||||
if (emergencyBtn) {
|
||||
emergencyBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Wirklich Hilfe rufen?\nIch alarmiere sofort Helfer!")) return;
|
||||
const lang = (langSel && langSel.value) || "de";
|
||||
// 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;
|
||||
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;
|
||||
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
|
||||
});
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 Assistant</h1>
|
||||
<button type="button" id="emergency-btn" title="Hilfe rufen" aria-label="Hilfe rufen"
|
||||
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>
|
||||
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>
|
||||
<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,6 +243,13 @@
|
|||
<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>
|
||||
|
|
@ -365,6 +372,45 @@
|
|||
<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>
|
||||
|
||||
<script src="/app.js?v=48"></script>
|
||||
<!-- ═══════════════ 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
96
tests/test_admin_app_login.py
Normal file
96
tests/test_admin_app_login.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""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
|
||||
133
tests/test_emergency_cooldown.py
Normal file
133
tests/test_emergency_cooldown.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""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")
|
||||
|
|
@ -99,6 +99,9 @@ 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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ 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():
|
||||
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)
|
||||
# Unbekannte Sprache -> deutscher Fallback-Hinweis
|
||||
en = client.post("/api/emergency", json={"language": "en"}).json()["notice"]
|
||||
assert "ATTENTION" in en
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue