update
This commit is contained in:
@@ -6,6 +6,7 @@ Geraeteverwaltung, Kanaele, Praxis-Federation.
|
||||
Alle Daten practice-scoped. Backend ist die einzige Wahrheit.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -14,6 +15,7 @@ import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
@@ -691,24 +693,54 @@ def _extract_client_ip(request: Request) -> str:
|
||||
EMPFANG_MESSAGE_RETENTION_DAYS = 14
|
||||
|
||||
|
||||
def _utc_now_iso_z() -> str:
|
||||
"""UTC-Zeitstempel fuer Chat-Nachrichten, ISO-8601 mit Z (eindeutig UTC)."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _parse_msg_instant_utc_ts(raw: str) -> float:
|
||||
"""Parse eines gespeicherten Chat-Zeitstempels zu POSIX-Sekunden (UTC).
|
||||
Naive Legacy-Strings ('YYYY-MM-DD HH:MM:SS' / '...T...' ohne TZ) werden
|
||||
wie in Produktion ueblich als UTC interpretiert (Server/VPS typisch)."""
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return 0.0
|
||||
s_norm = s.replace(" ", "T", 1)
|
||||
try:
|
||||
if s_norm.endswith("Z"):
|
||||
dt = datetime.fromisoformat(s_norm.replace("Z", "+00:00"))
|
||||
return dt.timestamp()
|
||||
dt = datetime.fromisoformat(s_norm)
|
||||
if dt.tzinfo is not None:
|
||||
return dt.timestamp()
|
||||
return dt.replace(tzinfo=timezone.utc).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _msg_timestamp_for_retention(m: dict) -> str:
|
||||
return (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||||
|
||||
|
||||
def _message_within_retention(m: dict, cutoff_str: str) -> bool:
|
||||
"""Behalten wenn Zeitstempel unbekannt oder >= cutoff (ISO-artige Strings)."""
|
||||
def _msg_chrono_sort_key(m: dict) -> tuple[float, str]:
|
||||
raw = _msg_timestamp_for_retention(m)
|
||||
return (_parse_msg_instant_utc_ts(raw), str(m.get("id") or ""))
|
||||
|
||||
|
||||
def _message_within_retention(m: dict, cutoff_ts: float) -> bool:
|
||||
"""Behalten wenn Zeitstempel unbekannt oder Augenblick >= cutoff (UTC-Sekunden)."""
|
||||
t = _msg_timestamp_for_retention(m)
|
||||
if not t:
|
||||
return True
|
||||
return t >= cutoff_str
|
||||
ts = _parse_msg_instant_utc_ts(t)
|
||||
if ts <= 0:
|
||||
return True
|
||||
return ts >= cutoff_ts
|
||||
|
||||
|
||||
def _prune_messages_by_retention(messages: list[dict]) -> tuple[list[dict], int]:
|
||||
cutoff_str = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400),
|
||||
)
|
||||
kept = [m for m in messages if _message_within_retention(m, cutoff_str)]
|
||||
cutoff_ts = time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400
|
||||
kept = [m for m in messages if _message_within_retention(m, cutoff_ts)]
|
||||
return kept, len(messages) - len(kept)
|
||||
|
||||
|
||||
@@ -1225,15 +1257,54 @@ async def auth_provision(request: Request):
|
||||
pid = request.headers.get("X-Practice-Id", "").strip()
|
||||
pid = pid or (body.get("practice_id") or "").strip()
|
||||
|
||||
if not pid and email:
|
||||
try:
|
||||
from stripe_routes import lookup_practice_id_for_license_email
|
||||
resolved_from_license = False
|
||||
resolved_practice_name = ""
|
||||
|
||||
lp = lookup_practice_id_for_license_email(email)
|
||||
if lp:
|
||||
pid = lp.strip()
|
||||
if not pid:
|
||||
license_key_in = (body.get("license_key") or "").strip()
|
||||
cand_list: list[str] = []
|
||||
seen_c: set[str] = set()
|
||||
try:
|
||||
from stripe_routes import (
|
||||
list_distinct_practice_ids_for_license_email,
|
||||
list_distinct_practice_ids_for_license_key,
|
||||
)
|
||||
|
||||
for p in list_distinct_practice_ids_for_license_email(email):
|
||||
p = (p or "").strip()
|
||||
if p and p not in seen_c:
|
||||
seen_c.add(p)
|
||||
cand_list.append(p)
|
||||
for p in list_distinct_practice_ids_for_license_key(license_key_in):
|
||||
p = (p or "").strip()
|
||||
if p and p not in seen_c:
|
||||
seen_c.add(p)
|
||||
cand_list.append(p)
|
||||
except Exception as exc:
|
||||
print(f"[EMPFANG] lookup_practice_id_for_license_email: {exc}")
|
||||
print(f"[EMPFANG] license practice resolution: {exc}")
|
||||
|
||||
if len(cand_list) > 1:
|
||||
practices = _load_practices()
|
||||
candidates_payload = []
|
||||
for p in cand_list:
|
||||
pdata = practices.get(p) or {}
|
||||
pname = (pdata.get("name") or "").strip() or p
|
||||
candidates_payload.append({"practice_id": p, "practice_name": pname})
|
||||
return JSONResponse(content={
|
||||
"success": False,
|
||||
"step": "choose_practice",
|
||||
"message": (
|
||||
"Mehrere Praxen passen zu dieser Lizenz bzw. E-Mail. "
|
||||
"Bitte waehlen Sie die Praxis aus, der dieses Geraet zugehoeren soll."
|
||||
),
|
||||
"candidates": candidates_payload,
|
||||
})
|
||||
if len(cand_list) == 1:
|
||||
pid = cand_list[0]
|
||||
resolved_from_license = True
|
||||
practices = _load_practices()
|
||||
pdata = practices.get(pid) or {}
|
||||
resolved_practice_name = (pdata.get("name") or "").strip()
|
||||
|
||||
if not pid:
|
||||
practices = _load_practices()
|
||||
@@ -1290,12 +1361,17 @@ async def auth_provision(request: Request):
|
||||
if not has_admin:
|
||||
target["role"] = "admin"
|
||||
_save_accounts(accounts)
|
||||
return JSONResponse(content={
|
||||
body_out = {
|
||||
"success": True, "user_id": target["user_id"],
|
||||
"display_name": target["display_name"], "role": target["role"],
|
||||
"practice_id": pid,
|
||||
"action": "updated",
|
||||
})
|
||||
}
|
||||
if resolved_from_license:
|
||||
body_out["resolved_existing_practice"] = True
|
||||
if resolved_practice_name:
|
||||
body_out["resolved_practice_name"] = resolved_practice_name
|
||||
return JSONResponse(content=body_out)
|
||||
|
||||
has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid
|
||||
for a in accounts.values())
|
||||
@@ -1316,12 +1392,17 @@ async def auth_provision(request: Request):
|
||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
_save_accounts(accounts)
|
||||
return JSONResponse(content={
|
||||
body_created = {
|
||||
"success": True, "user_id": uid,
|
||||
"display_name": name, "role": role,
|
||||
"practice_id": pid,
|
||||
"action": "created",
|
||||
})
|
||||
}
|
||||
if resolved_from_license:
|
||||
body_created["resolved_existing_practice"] = True
|
||||
if resolved_practice_name:
|
||||
body_created["resolved_practice_name"] = resolved_practice_name
|
||||
return JSONResponse(content=body_created)
|
||||
|
||||
|
||||
@router.post("/auth/forgot_password")
|
||||
@@ -2572,6 +2653,7 @@ async def empfang_send(msg: EmpfangMessage, request: Request):
|
||||
else:
|
||||
thread_id = reply_to
|
||||
|
||||
_ts = _utc_now_iso_z()
|
||||
entry = {
|
||||
"id": msg_id,
|
||||
"thread_id": thread_id,
|
||||
@@ -2582,8 +2664,8 @@ async def empfang_send(msg: EmpfangMessage, request: Request):
|
||||
"kommentar": msg.kommentar.strip(),
|
||||
"patient": msg.patient.strip(),
|
||||
"absender": absender,
|
||||
"zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"empfangen": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"zeitstempel": _ts,
|
||||
"empfangen": _ts,
|
||||
"status": "offen",
|
||||
"user_id": s["user_id"] if s else "",
|
||||
}
|
||||
@@ -2661,10 +2743,13 @@ def _pulse_get(practice_id: str) -> dict:
|
||||
# Clients nach Server-Restart nicht alle "neue Nachricht!" denken.
|
||||
msgs = _filter_by_practice(_load_messages(), practice_id)
|
||||
latest = ""
|
||||
latest_ts = 0.0
|
||||
for m in msgs:
|
||||
t = (m.get("empfangen") or m.get("zeitstempel") or "")
|
||||
if t > latest:
|
||||
latest = t
|
||||
t_raw = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||||
ts = _parse_msg_instant_utc_ts(t_raw)
|
||||
if ts >= latest_ts and t_raw:
|
||||
latest_ts = ts
|
||||
latest = t_raw
|
||||
p = {"tick": 1, "ts": time.time(), "last_sender": "", "boot": latest}
|
||||
_PRACTICE_PULSE[practice_id] = p
|
||||
return p
|
||||
@@ -3401,7 +3486,7 @@ def _conversation_for_audience(
|
||||
if _thread_requires_broadcast_exclusion(messages, pid, tid):
|
||||
continue
|
||||
out.append(m)
|
||||
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
|
||||
out.sort(key=_msg_chrono_sort_key)
|
||||
return out
|
||||
|
||||
# --- Gruppen-Chat ---
|
||||
@@ -3424,7 +3509,7 @@ def _conversation_for_audience(
|
||||
sn = _norm_name(_sender_core(m.get("absender", "")))
|
||||
if sn and sn in participants:
|
||||
out.append(m)
|
||||
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
|
||||
out.sort(key=_msg_chrono_sort_key)
|
||||
return out
|
||||
|
||||
# --- 1:1 Direktverlauf ---
|
||||
@@ -3440,7 +3525,7 @@ def _conversation_for_audience(
|
||||
aud_raw,
|
||||
)
|
||||
out.extend(dm_list)
|
||||
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
|
||||
out.sort(key=_msg_chrono_sort_key)
|
||||
return out
|
||||
|
||||
|
||||
@@ -3670,7 +3755,7 @@ def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict],
|
||||
ex = m.get("extras") or {}
|
||||
if str(ex.get("direct_conv_key") or "").strip() == conv_key:
|
||||
out.append(m)
|
||||
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
|
||||
out.sort(key=_msg_chrono_sort_key)
|
||||
return out, conv_key
|
||||
|
||||
|
||||
@@ -3717,7 +3802,7 @@ async def empfang_dm_send(payload: DmSendIn, request: Request):
|
||||
conv_key = _direct_conv_key(pid, sender_uid, recipient_uid)
|
||||
|
||||
msg_id = uuid.uuid4().hex[:12]
|
||||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
now = _utc_now_iso_z()
|
||||
extras = {
|
||||
"audience": "direct",
|
||||
"rcpt_broadcast": False,
|
||||
@@ -3854,7 +3939,7 @@ async def empfang_thread(thread_id: str, request: Request,
|
||||
messages = _load_messages()
|
||||
thread = [m for m in messages
|
||||
if m.get("thread_id") == thread_id and _msg_practice(m) == pid]
|
||||
thread.sort(key=lambda m: m.get("empfangen", ""))
|
||||
thread.sort(key=_msg_chrono_sort_key)
|
||||
return JSONResponse(content={"success": True, "messages": thread})
|
||||
|
||||
|
||||
@@ -3877,6 +3962,228 @@ async def empfang_done(msg_id: str):
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
EMPFANG_MSG_TRANSCRIBE_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _attachment_is_audio_dict(a: object) -> bool:
|
||||
if not isinstance(a, dict):
|
||||
return False
|
||||
if str(a.get("kind") or "").strip().lower() == "audio":
|
||||
return True
|
||||
mt = str(a.get("mime") or "").strip().lower()
|
||||
if mt.startswith("audio/"):
|
||||
return True
|
||||
n = str(a.get("name") or "").strip().lower()
|
||||
return bool(
|
||||
re.search(r"\.(webm|ogg|opus|wav|mp3|m4a)$", n, re.I)
|
||||
)
|
||||
|
||||
|
||||
def _audio_suffix_for_attachment(att: dict) -> str:
|
||||
"""Endung fuer die Tempfile abgeleitet aus dem MIME-Typ.
|
||||
|
||||
Wichtig: ``audio/webm`` und ``audio/webm;codecs=opus`` muessen ``.webm`` werden,
|
||||
weil die Bytes ein WebM-Container sind (Edge/Chrome MediaRecorder). Erst danach
|
||||
werden echte OGG-/Opus-Faelle wie ``audio/ogg`` oder ``audio/opus`` als ``.ogg``
|
||||
bzw. ``.opus`` behandelt. Falsche Container-/Endungs-Kombination ist ein
|
||||
bekannter Ausloeser fuer OpenAI BadRequestError.
|
||||
"""
|
||||
mt = str(att.get("mime") or "").lower()
|
||||
if "webm" in mt:
|
||||
return ".webm"
|
||||
if "wav" in mt:
|
||||
return ".wav"
|
||||
if "mp4" in mt or "m4a" in mt or "aac" in mt:
|
||||
return ".m4a"
|
||||
if "ogg" in mt:
|
||||
return ".ogg"
|
||||
if "opus" in mt:
|
||||
return ".opus"
|
||||
if "mpeg" in mt or "mp3" in mt:
|
||||
return ".mp3"
|
||||
n = str(att.get("name") or "").lower()
|
||||
if n.endswith(".wav"):
|
||||
return ".wav"
|
||||
if n.endswith(".m4a"):
|
||||
return ".m4a"
|
||||
if n.endswith(".ogg"):
|
||||
return ".ogg"
|
||||
if n.endswith(".opus"):
|
||||
return ".opus"
|
||||
if n.endswith(".mp3"):
|
||||
return ".mp3"
|
||||
return ".webm"
|
||||
|
||||
|
||||
def _empfang_transcribe_openai_from_bytes(
|
||||
audio_bytes: bytes,
|
||||
*,
|
||||
filename_suffix: str = ".webm",
|
||||
) -> str:
|
||||
"""Eine Audiodatei transkribieren (OpenAI wie backend /v1/transcribe). Tempfile wird geloescht."""
|
||||
import backend_main as bm
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
client = bm._get_openai()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="aza_ef_tr_",
|
||||
suffix=filename_suffix,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(audio_bytes)
|
||||
tmp_path = tmp.name
|
||||
with open(tmp_path, "rb") as f:
|
||||
is_gpt = "gpt-" in bm.TRANSCRIBE_MODEL
|
||||
params: dict = dict(model=bm.TRANSCRIBE_MODEL, file=f, language="de")
|
||||
dom = "medical"
|
||||
chosen = (
|
||||
bm.WHISPER_GENERAL_PROMPT
|
||||
if dom == "general"
|
||||
else bm.WHISPER_MEDICAL_PROMPT
|
||||
)
|
||||
if is_gpt:
|
||||
params["prompt"] = bm.GPT_TRANSCRIBE_SHORT_PROMPT
|
||||
else:
|
||||
params["prompt"] = chosen
|
||||
params["temperature"] = 0.0
|
||||
resp = client.audio.transcriptions.create(**params)
|
||||
text = getattr(resp, "text", "") or ""
|
||||
if not text:
|
||||
try:
|
||||
if hasattr(resp, "model_dump"):
|
||||
dd = resp.model_dump()
|
||||
if isinstance(dd, dict):
|
||||
text = dd.get("text", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
t_stripped = text.lstrip()
|
||||
if t_stripped.startswith(bm.WHISPER_PROMPT_PREFIX):
|
||||
text = t_stripped[len(bm.WHISPER_PROMPT_PREFIX) :].lstrip(" :\t\r\n-")
|
||||
text = (text or "").replace("ß", "ss")
|
||||
text = bm.apply_medical_corrections(text, "")
|
||||
text = bm.apply_medical_post_corrections(text)
|
||||
text = bm.apply_medication_fuzzy_corrections(text)
|
||||
return (text or "").strip()
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/messages/{msg_id}/transcribe-audio")
|
||||
async def empfang_message_transcribe_audio(msg_id: str, request: Request):
|
||||
"""Transkribiert das erste Audio-Attachment einer Nachricht; speichert ``transcript`` am Attachment."""
|
||||
_require_session(request)
|
||||
pid = _require_practice_id(request)
|
||||
messages = _load_messages()
|
||||
target = next((m for m in messages if m.get("id") == msg_id), None)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
|
||||
if _msg_practice(target) != pid:
|
||||
raise HTTPException(status_code=403, detail="Kein Zugriff")
|
||||
ex = dict(target.get("extras") or {})
|
||||
attachments = ex.get("attachments")
|
||||
if not isinstance(attachments, list):
|
||||
attachments = []
|
||||
idx: Optional[int] = None
|
||||
att: Optional[dict] = None
|
||||
for i, raw_a in enumerate(attachments):
|
||||
if isinstance(raw_a, dict) and _attachment_is_audio_dict(raw_a):
|
||||
idx = i
|
||||
att = raw_a
|
||||
break
|
||||
if idx is None or not att:
|
||||
raise HTTPException(status_code=400, detail="Keine Audiodatei in dieser Nachricht")
|
||||
existing = str(att.get("transcript") or "").strip()
|
||||
if existing:
|
||||
mid_short = (msg_id or "")[:12]
|
||||
_log.info(
|
||||
"EMPFANG_TRANSCRIBE_CACHE msg=%s att=%s",
|
||||
mid_short,
|
||||
idx,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"transcript": existing,
|
||||
"cached": True,
|
||||
"attachment_index": idx,
|
||||
}
|
||||
)
|
||||
b64 = str(att.get("data") or "").strip()
|
||||
if not b64:
|
||||
raise HTTPException(status_code=400, detail="Audiodaten fehlen")
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=False)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Audiodaten ungueltig")
|
||||
nbytes = len(raw)
|
||||
if nbytes < 1 or nbytes > EMPFANG_MSG_TRANSCRIBE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Audiodatei zu gross")
|
||||
suffix = _audio_suffix_for_attachment(att)
|
||||
try:
|
||||
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
mid_short = (msg_id or "")[:12]
|
||||
err_type = type(exc).__name__
|
||||
try:
|
||||
err_short = str(exc)
|
||||
except Exception:
|
||||
err_short = ""
|
||||
err_short = (err_short or "").strip()
|
||||
if len(err_short) > 200:
|
||||
err_short = err_short[:200]
|
||||
if err_short:
|
||||
err_short = err_short.replace("\n", " ").replace("\r", " ")
|
||||
_log.warning(
|
||||
"EMPFANG_TRANSCRIBE_FAIL msg=%s bytes=%s suffix=%s err=%s short=%s",
|
||||
mid_short,
|
||||
nbytes,
|
||||
suffix,
|
||||
err_type,
|
||||
err_short,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"OpenAI: {err_type}",
|
||||
) from exc
|
||||
if not transcript:
|
||||
mid_short = (msg_id or "")[:12]
|
||||
_log.info("EMPFANG_TRANSCRIBE_EMPTY msg=%s bytes=%s", mid_short, nbytes)
|
||||
raise HTTPException(status_code=502, detail="Leeres Transkript")
|
||||
att_new = dict(att)
|
||||
att_new["transcript"] = transcript
|
||||
att_new["transcript_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
attachments = list(attachments)
|
||||
attachments[idx] = att_new
|
||||
ex["attachments"] = attachments
|
||||
target["extras"] = ex
|
||||
_save_messages(messages)
|
||||
try:
|
||||
_pulse_bump(pid, sender="")
|
||||
except Exception:
|
||||
pass
|
||||
mid_short = (msg_id or "")[:12]
|
||||
_log.info(
|
||||
"EMPFANG_TRANSCRIBE_OK msg=%s bytes=%s",
|
||||
mid_short,
|
||||
nbytes,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"transcript": transcript,
|
||||
"cached": False,
|
||||
"attachment_index": idx,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/messages/{msg_id}/chat-ack")
|
||||
async def empfang_message_chat_ack(msg_id: str, request: Request):
|
||||
"""OK/Kenntnisnahme pro Nachricht: nur ``extras.chat_ack`` (kein Thread-Status)."""
|
||||
@@ -4302,17 +4609,18 @@ async def empfang_cleanup(request: Request):
|
||||
pid = (body.get("practice_id") or "").strip() or _resolve_practice_id(request)
|
||||
if not pid:
|
||||
return JSONResponse(content={"success": True, "removed": 0, "remaining": 0})
|
||||
cutoff = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - max_days * 86400),
|
||||
)
|
||||
cutoff_ts = time.time() - max_days * 86400
|
||||
messages = _load_messages()
|
||||
before = len(messages)
|
||||
kept = [
|
||||
m for m in messages
|
||||
if _msg_practice(m) != pid
|
||||
or (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff
|
||||
]
|
||||
kept = []
|
||||
for m in messages:
|
||||
if _msg_practice(m) != pid:
|
||||
kept.append(m)
|
||||
continue
|
||||
raw_t = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
|
||||
ts = _parse_msg_instant_utc_ts(raw_t)
|
||||
if ts <= 0 or ts >= cutoff_ts:
|
||||
kept.append(m)
|
||||
removed = before - len(kept)
|
||||
if removed > 0:
|
||||
_save_messages(kept)
|
||||
@@ -4338,6 +4646,7 @@ async def empfang_practice_info(request: Request):
|
||||
result = {
|
||||
"practice_id": pid,
|
||||
"practice_name": p.get("name", ""),
|
||||
"practice_timezone": str(p.get("timezone") or "").strip(),
|
||||
"user_count": len(users),
|
||||
"message_count": len(messages),
|
||||
"open_count": open_count,
|
||||
@@ -4744,14 +5053,20 @@ async def empfang_shell_consume(request: Request):
|
||||
async def empfang_shell_launch(
|
||||
request: Request,
|
||||
token: str = Query("", description="kurzlebiger Shell-Token (einmaliger Verbrauch)"),
|
||||
target: str = Query("", description="optional: 'empfang_chat_shell' fuer Empfang-Huelle"),
|
||||
):
|
||||
"""Web-Huelle: Token per GET einloesen — setzt Cookie, Redirect ohne Token in URL.
|
||||
|
||||
Einmal-Verbrauch. Kein MEDWORK_API-Token hier (Browser/WebView ohne Desktop-Secret).
|
||||
target=empfang_chat_shell markiert die separate Empfang-Chat-Huelle (kein Arzt-Desktop).
|
||||
"""
|
||||
sess_token, _pub = _consume_shell_token_core(request, token)
|
||||
|
||||
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
|
||||
t = (target or "").strip().lower()
|
||||
if t == "empfang_chat_shell":
|
||||
loc = "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell"
|
||||
else:
|
||||
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
|
||||
resp = RedirectResponse(url=loc, status_code=302)
|
||||
resp.set_cookie(
|
||||
"aza_session",
|
||||
@@ -4763,6 +5078,187 @@ async def empfang_shell_launch(
|
||||
return resp
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Empfang-Chat-Huelle: Browser->native-Huelle Handoff
|
||||
# =====================================================================
|
||||
#
|
||||
# Ziel: Eingeloggter Browser-Empfang kann die separat installierte
|
||||
# native Empfang-Chat-Huelle (AZA_EmpfangShell.exe) mit derselben Praxis-/
|
||||
# Chat-Session verbinden, OHNE Arztlizenz / X-API-Token.
|
||||
#
|
||||
# Sicherheitsmodell:
|
||||
# - /empfang/handoff/create erfordert eingeloggte Empfang-Session (Cookie).
|
||||
# - Erzeugt einen kurzlebigen shell_token (gleiches _shell_store wie Desktop-
|
||||
# Launch) plus einen kurzen, lesbaren Verbindungscode XXXX-XXXX, der nur
|
||||
# auf den shell_token verweist (eigener Store, einmalig konsumiert).
|
||||
# - /empfang/handoff/lookup loest den Verbindungscode in den shell_token auf.
|
||||
# Kein Login noetig - der Code IST das Geheimnis. Einmal-Verbrauch.
|
||||
# - Der shell_token selbst wird wie gewohnt durch /empfang/shell/launch
|
||||
# einmal verbraucht (HttpOnly Cookie in der nativen Huelle gesetzt).
|
||||
# - Keine Chatdaten werden im Handoff transportiert.
|
||||
|
||||
_HANDOFF_TTL_DEFAULT = 300 # 5 Minuten
|
||||
_HANDOFF_PURPOSE = "empfang_chat_shell_handoff"
|
||||
_handoff_short_codes: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _handoff_short_codes_cleanup() -> None:
|
||||
now = time.time()
|
||||
stale = [
|
||||
c for c, rec in _handoff_short_codes.items()
|
||||
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
|
||||
]
|
||||
for c in stale:
|
||||
try:
|
||||
del _handoff_short_codes[c]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_short_code(raw: str) -> str:
|
||||
"""Vergleichbar machen: Grossbuchstaben, Bindestrich-Varianten -> '-', Leerzeichen weg."""
|
||||
s = (raw or "").strip().upper().replace(" ", "")
|
||||
for ch in ("\u2011", "\u2013", "\u2014", "\u2212", "_"):
|
||||
s = s.replace(ch, "-")
|
||||
return s
|
||||
|
||||
|
||||
def _generate_short_handoff_code() -> str:
|
||||
"""8 Zeichen lesbar, ohne 0/O/1/I-Verwechslungen, im Format XXXX-XXXX."""
|
||||
import random
|
||||
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
a = "".join(random.choices(chars, k=4))
|
||||
b = "".join(random.choices(chars, k=4))
|
||||
return f"{a}-{b}"
|
||||
|
||||
|
||||
@router.post("/handoff/create")
|
||||
async def empfang_handoff_create(request: Request):
|
||||
"""Browser-Empfang -> Empfang-Chat-Huelle: kurzlebiger Handoff-Token + Verbindungscode.
|
||||
|
||||
Erfordert eine bestehende Empfang-Session (HttpOnly-Cookie).
|
||||
Keine Arzt-Lizenz / kein X-API-Token. Kein Chat-Inhalt im Token.
|
||||
"""
|
||||
_shell_cleanup_expired()
|
||||
_handoff_short_codes_cleanup()
|
||||
|
||||
s = _require_session(request)
|
||||
pid = (s.get("practice_id") or "").strip()
|
||||
uid = (s.get("user_id") or "").strip()
|
||||
if not pid or not uid:
|
||||
raise HTTPException(status_code=400, detail="Session unvollstaendig")
|
||||
|
||||
accounts = _load_accounts()
|
||||
acc = accounts.get(uid) or {}
|
||||
display_name = (acc.get("display_name")
|
||||
or s.get("display_name") or "").strip() or "Benutzer"
|
||||
role = (acc.get("role") or s.get("role") or "mpa").strip()
|
||||
if (acc.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
|
||||
|
||||
now = time.time()
|
||||
expires_at = now + float(_HANDOFF_TTL_DEFAULT)
|
||||
shell_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Eindeutigen kurzen Verbindungscode erzeugen
|
||||
short_code = _generate_short_handoff_code()
|
||||
tries = 0
|
||||
while short_code in _handoff_short_codes and tries < 8:
|
||||
short_code = _generate_short_handoff_code()
|
||||
tries += 1
|
||||
|
||||
_shell_store[shell_token] = {
|
||||
"practice_id": pid,
|
||||
"user_id": uid,
|
||||
"display_name": display_name,
|
||||
"role": role,
|
||||
"purpose": _HANDOFF_PURPOSE,
|
||||
"expires_at": expires_at,
|
||||
"created_at": now,
|
||||
}
|
||||
_handoff_short_codes[short_code] = {
|
||||
"shell_token": shell_token,
|
||||
"expires_at": expires_at,
|
||||
"practice_id": pid,
|
||||
"user_id": uid,
|
||||
}
|
||||
|
||||
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
|
||||
launch_path = (
|
||||
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
|
||||
)
|
||||
|
||||
_log.info(
|
||||
"AZA_EMPFANG_HANDOFF_CREATED practice=%s user=%s purpose=%s ttl=%s",
|
||||
pid, uid, _HANDOFF_PURPOSE, int(_HANDOFF_TTL_DEFAULT),
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"short_code": short_code,
|
||||
"launch_path": launch_path,
|
||||
"expires_at": expires_iso,
|
||||
"expires_at_unix": int(expires_at),
|
||||
"ttl_seconds": int(_HANDOFF_TTL_DEFAULT),
|
||||
"purpose": _HANDOFF_PURPOSE,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/handoff/lookup")
|
||||
async def empfang_handoff_lookup(
|
||||
request: Request,
|
||||
code: str = Query("", description="kurzer Verbindungscode XXXX-XXXX"),
|
||||
):
|
||||
"""Empfang-Chat-Huelle: Verbindungscode in shell_token aufloesen.
|
||||
|
||||
Kein Cookie/Session noetig — der Code IST das Geheimnis. Einmal-Verbrauch
|
||||
der Code->Token-Zuordnung. Der shell_token wird erst spaeter durch
|
||||
/empfang/shell/launch eingeloest.
|
||||
"""
|
||||
_shell_cleanup_expired()
|
||||
_handoff_short_codes_cleanup()
|
||||
|
||||
c = _normalize_short_code(code)
|
||||
if not c:
|
||||
raise HTTPException(status_code=400, detail="code fehlt")
|
||||
|
||||
rec = _handoff_short_codes.get(c)
|
||||
if not rec:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Verbindungscode ungueltig oder bereits eingeloest",
|
||||
)
|
||||
if time.time() > float(rec.get("expires_at", 0)):
|
||||
try:
|
||||
del _handoff_short_codes[c]
|
||||
except KeyError:
|
||||
pass
|
||||
raise HTTPException(status_code=410, detail="Verbindungscode abgelaufen")
|
||||
|
||||
shell_token = (rec.get("shell_token") or "").strip()
|
||||
try:
|
||||
del _handoff_short_codes[c]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if not shell_token or shell_token not in _shell_store:
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="Verbindungscode war gueltig, aber zugehoerige Session abgelaufen",
|
||||
)
|
||||
|
||||
launch_path = (
|
||||
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
|
||||
)
|
||||
_log.info("AZA_EMPFANG_HANDOFF_REDEEMED purpose=%s", _HANDOFF_PURPOSE)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"launch_path": launch_path,
|
||||
"purpose": _HANDOFF_PURPOSE,
|
||||
})
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# HTML PAGE
|
||||
# =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user