update
This commit is contained in:
@@ -356,6 +356,62 @@ def _practice_label(practices: dict, pid: str) -> str:
|
||||
return (p.get("name") or "").strip() or pid
|
||||
|
||||
|
||||
def _invite_join_clone_account(
|
||||
accounts: dict,
|
||||
source: dict,
|
||||
target_pid: str,
|
||||
now: str,
|
||||
) -> dict:
|
||||
"""Second account in target practice with same credentials (cross-practice chat join).
|
||||
|
||||
Verhindert stillen Login in der alten Praxis, wenn ein gueltiger Einladungscode
|
||||
die Ziel-practice_id festlegt. Keine Praxis-Migration: Quellkonto bleibt unveraendert.
|
||||
"""
|
||||
display = (source.get("display_name") or "").strip()
|
||||
if not display:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Ungueltiges Quellkonto.",
|
||||
)
|
||||
if any(
|
||||
_normalize_login_username(a.get("display_name") or "")
|
||||
== _normalize_login_username(display)
|
||||
and a.get("practice_id") == target_pid
|
||||
for a in accounts.values()
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"In dieser Praxis existiert bereits ein Benutzer mit diesem Namen. "
|
||||
"Bitte melden Sie sich mit diesem Konto an."
|
||||
),
|
||||
)
|
||||
uid = uuid.uuid4().hex[:12]
|
||||
pref_ln = (source.get("login_name") or "").strip() or display
|
||||
ln_assign = _preferred_unique_login_for_display(accounts, target_pid, pref_ln, "")
|
||||
role_s = (source.get("role") or "mpa").strip()
|
||||
if role_s == "admin":
|
||||
role_s = "mpa"
|
||||
elif role_s not in ("arzt", "mpa", "empfang"):
|
||||
role_s = "mpa"
|
||||
accounts[uid] = {
|
||||
"user_id": uid,
|
||||
"practice_id": target_pid,
|
||||
"display_name": display,
|
||||
"email": (source.get("email") or "").strip(),
|
||||
"login_name": ln_assign,
|
||||
"role": role_s,
|
||||
"pw_hash": source["pw_hash"],
|
||||
"pw_salt": source["pw_salt"],
|
||||
"created": now,
|
||||
"status": "active",
|
||||
"last_login": now,
|
||||
}
|
||||
if source.get("must_change_password"):
|
||||
accounts[uid]["must_change_password"] = True
|
||||
return accounts[uid]
|
||||
|
||||
|
||||
def _mask_email_for_response(addr: str) -> str:
|
||||
"""Kurze Maskierung fuer API-Antworten (kein Klartext der vollstaendigen Adresse)."""
|
||||
s = (addr or "").strip()
|
||||
@@ -995,7 +1051,8 @@ async def auth_login(request: Request):
|
||||
)
|
||||
if len(gmatches) == 1:
|
||||
target = gmatches[0]
|
||||
login_recovered_practice = True
|
||||
if pid_src != "invite":
|
||||
login_recovered_practice = True
|
||||
|
||||
if not target:
|
||||
raise HTTPException(
|
||||
@@ -1003,7 +1060,8 @@ async def auth_login(request: Request):
|
||||
detail="Benutzer nicht gefunden oder falsches Passwort",
|
||||
)
|
||||
tpid = (target.get("practice_id") or "").strip()
|
||||
if pid and tpid != pid and not login_recovered_practice:
|
||||
invite_will_clone = bool(pid_src == "invite" and pid and tpid and tpid != pid)
|
||||
if pid and tpid != pid and not login_recovered_practice and not invite_will_clone:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
@@ -1012,7 +1070,8 @@ async def auth_login(request: Request):
|
||||
),
|
||||
)
|
||||
if not pid or login_recovered_practice:
|
||||
pid = tpid
|
||||
if not invite_will_clone:
|
||||
pid = tpid
|
||||
if target.get("status") == "deactivated":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -1024,8 +1083,15 @@ async def auth_login(request: Request):
|
||||
detail="Benutzer nicht gefunden oder falsches Passwort",
|
||||
)
|
||||
|
||||
did_invite_clone = False
|
||||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
target["last_login"] = now
|
||||
if invite_will_clone:
|
||||
_ensure_practice(pid)
|
||||
_ensure_default_channels(pid)
|
||||
target = _invite_join_clone_account(accounts, target, pid, now)
|
||||
did_invite_clone = True
|
||||
else:
|
||||
target["last_login"] = now
|
||||
_save_accounts(accounts)
|
||||
|
||||
dn = (target.get("display_name") or raw).strip()
|
||||
@@ -1038,15 +1104,19 @@ async def auth_login(request: Request):
|
||||
)
|
||||
|
||||
bind_src = (
|
||||
"invite_code"
|
||||
if pid_src == "invite"
|
||||
"invite_code_join"
|
||||
if did_invite_clone
|
||||
else (
|
||||
"username_recovered_practice"
|
||||
if login_recovered_practice
|
||||
"invite_code"
|
||||
if pid_src == "invite"
|
||||
else (
|
||||
"stored_practice_id"
|
||||
if pid_src in ("body", "header", "query")
|
||||
else "account"
|
||||
"username_recovered_practice"
|
||||
if login_recovered_practice
|
||||
else (
|
||||
"stored_practice_id"
|
||||
if pid_src in ("body", "header", "query")
|
||||
else "account"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -3613,12 +3683,24 @@ async def empfang_pulse(request: Request, practice_id: Optional[str] = Query(Non
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
p = _pulse_get(pid)
|
||||
tick = int(p.get("tick", 0))
|
||||
content: dict = {
|
||||
"tick": tick,
|
||||
"ts": float(p.get("ts", 0.0)),
|
||||
"last_sender": p.get("last_sender", ""),
|
||||
}
|
||||
try:
|
||||
s = _session_from_request(request)
|
||||
if s:
|
||||
me_uid = str(s.get("user_id") or "").strip()
|
||||
if me_uid:
|
||||
snap = _pulse_dm_pending_ack_for_tick(pid, me_uid, tick)
|
||||
content["dm_pending_ack_by_peer"] = snap["by_peer"]
|
||||
content["dm_pending_ack_total"] = int(snap["total"])
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse(
|
||||
content={
|
||||
"tick": int(p.get("tick", 0)),
|
||||
"ts": float(p.get("ts", 0.0)),
|
||||
"last_sender": p.get("last_sender", ""),
|
||||
},
|
||||
content=content,
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
@@ -3759,6 +3841,51 @@ def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict],
|
||||
return out, conv_key
|
||||
|
||||
|
||||
def _compute_dm_pending_ack_by_peer(pid: str, me_uid: str) -> dict[str, int]:
|
||||
"""Eingehende DM an mich ohne chat_ack: Zähler pro Absender-user_id (für Badges / Alarm).
|
||||
|
||||
Nutzt dieselben Felder wie /dm/conversation (direct_conv_key, sender/recipient_user_id).
|
||||
"""
|
||||
by_peer: dict[str, int] = {}
|
||||
if not pid or not me_uid:
|
||||
return by_peer
|
||||
for m in _filter_by_practice(_load_messages(), pid):
|
||||
ex = m.get("extras") or {}
|
||||
if not str(ex.get("direct_conv_key") or "").strip():
|
||||
continue
|
||||
su = str(ex.get("sender_user_id") or "").strip()
|
||||
ru = str(ex.get("recipient_user_id") or "").strip()
|
||||
if not su or su == me_uid:
|
||||
continue
|
||||
if ru != me_uid:
|
||||
continue
|
||||
if bool(ex.get("chat_ack")):
|
||||
continue
|
||||
by_peer[su] = by_peer.get(su, 0) + 1
|
||||
return by_peer
|
||||
|
||||
|
||||
_PENDING_ACK_CACHE_MAX = 128
|
||||
_DM_PENDING_ACK_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _pulse_dm_pending_ack_for_tick(pid: str, me_uid: str, tick: int) -> dict:
|
||||
"""Pro (Praxis, Nutzer, Pulse-Tick) einmal berechnen, dann cachen (Tick bump = neue Daten)."""
|
||||
key = f"{pid}|{me_uid}|{tick}"
|
||||
hit = _DM_PENDING_ACK_CACHE.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
by_peer = _compute_dm_pending_ack_by_peer(pid, me_uid)
|
||||
out = {
|
||||
"by_peer": by_peer,
|
||||
"total": int(sum(by_peer.values())),
|
||||
}
|
||||
if len(_DM_PENDING_ACK_CACHE) > _PENDING_ACK_CACHE_MAX:
|
||||
_DM_PENDING_ACK_CACHE.clear()
|
||||
_DM_PENDING_ACK_CACHE[key] = out
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/dm/send")
|
||||
async def empfang_dm_send(payload: DmSendIn, request: Request):
|
||||
"""Direct-Only Senden. Fail-Closed:
|
||||
@@ -4186,26 +4313,89 @@ async def empfang_message_transcribe_audio(msg_id: str, request: Request):
|
||||
|
||||
@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)."""
|
||||
_require_session(request)
|
||||
"""OK/Kenntnisnahme pro Nachricht (``extras.chat_ack``).
|
||||
|
||||
Optionale Bulk-Variante (rueckwaerts-kompatibel): Body
|
||||
``{"ack": true, "scope": "thread_until_message"}`` quittiert zusaetzlich
|
||||
*alle* aelteren eingehenden Nachrichten desselben ``direct_conv_key`` an
|
||||
den aktuellen Empfaenger (Session-User), die noch ohne ``chat_ack`` sind
|
||||
und chronologisch <= der geklickten Nachricht liegen.
|
||||
|
||||
- Eigene Nachrichten werden nie quittiert.
|
||||
- Nichts ausserhalb der eigenen Praxis.
|
||||
- Nichts in anderen Conversations / anderen Absendern.
|
||||
- Bei ``ack=false`` (Toggle aus) wirkt nur die geklickte Nachricht.
|
||||
"""
|
||||
s = _require_session(request)
|
||||
pid = _require_practice_id(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
ack = bool(body.get("ack"))
|
||||
scope = str((body.get("scope") or "")).strip().lower()
|
||||
me_uid = str((s.get("user_id") if s else "") or "").strip()
|
||||
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 {})
|
||||
if ack:
|
||||
ex["chat_ack"] = True
|
||||
|
||||
bulk_targets: list[dict] = []
|
||||
if ack and scope == "thread_until_message":
|
||||
ex_clk = target.get("extras") or {}
|
||||
clk_conv = str(ex_clk.get("direct_conv_key") or "").strip()
|
||||
clk_recipient = str(ex_clk.get("recipient_user_id") or "").strip()
|
||||
clk_sender = str(ex_clk.get("sender_user_id") or "").strip()
|
||||
clk_chrono = _msg_chrono_sort_key(target)
|
||||
# Bulk-Ack nur, wenn die geklickte Nachricht eine DM an den Session-User ist.
|
||||
# Schutz gegen versehentliches Bulk auf eigene gesendete Nachrichten.
|
||||
if clk_conv and me_uid and clk_recipient == me_uid and clk_sender and clk_sender != me_uid:
|
||||
for m in messages:
|
||||
if _msg_practice(m) != pid:
|
||||
continue
|
||||
ex_m = m.get("extras") or {}
|
||||
if str(ex_m.get("direct_conv_key") or "").strip() != clk_conv:
|
||||
continue
|
||||
if str(ex_m.get("recipient_user_id") or "").strip() != me_uid:
|
||||
continue
|
||||
su_m = str(ex_m.get("sender_user_id") or "").strip()
|
||||
if not su_m or su_m == me_uid:
|
||||
continue
|
||||
if su_m != clk_sender:
|
||||
continue
|
||||
if bool(ex_m.get("chat_ack")):
|
||||
continue
|
||||
if _msg_chrono_sort_key(m) > clk_chrono:
|
||||
continue
|
||||
bulk_targets.append(m)
|
||||
|
||||
affected = 0
|
||||
if bulk_targets:
|
||||
for m in bulk_targets:
|
||||
ex_m = dict(m.get("extras") or {})
|
||||
ex_m["chat_ack"] = True
|
||||
m["extras"] = ex_m
|
||||
affected += 1
|
||||
# Klick-Ziel ist normalerweise schon enthalten (chrono <= clk_chrono),
|
||||
# aber sicherheitshalber separat sicherstellen:
|
||||
ex_t = dict(target.get("extras") or {})
|
||||
if not ex_t.get("chat_ack"):
|
||||
ex_t["chat_ack"] = True
|
||||
target["extras"] = ex_t
|
||||
affected += 1
|
||||
else:
|
||||
ex.pop("chat_ack", None)
|
||||
target["extras"] = ex
|
||||
ex = dict(target.get("extras") or {})
|
||||
if ack:
|
||||
if not ex.get("chat_ack"):
|
||||
ex["chat_ack"] = True
|
||||
affected = 1
|
||||
else:
|
||||
if ex.pop("chat_ack", None) is not None:
|
||||
affected = 1
|
||||
target["extras"] = ex
|
||||
|
||||
_save_messages(messages)
|
||||
try:
|
||||
_pulse_bump(pid, sender="")
|
||||
@@ -4213,8 +4403,16 @@ async def empfang_message_chat_ack(msg_id: str, request: Request):
|
||||
pass
|
||||
mid_short = (msg_id or "")[:16]
|
||||
pid_short = (pid or "")[:16]
|
||||
_log.info("AZA_CHAT_ACK practice=%s msg=%s ack=%s", pid_short, mid_short, ack)
|
||||
return JSONResponse(content={"success": True, "ack": ack})
|
||||
_log.info(
|
||||
"AZA_CHAT_ACK practice=%s msg=%s ack=%s scope=%s affected=%s",
|
||||
pid_short, mid_short, ack, scope or "single", affected,
|
||||
)
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"ack": ack,
|
||||
"scope": scope or "single",
|
||||
"affected": affected,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/messages/{msg_id}")
|
||||
@@ -4938,6 +5136,15 @@ async def empfang_shell_context_upload(request: Request):
|
||||
therapy_autocopy = body.get("therapy_autocopy") in (True, "true", "1", 1)
|
||||
procedure_autocopy = body.get("procedure_autocopy") in (True, "true", "1", 1)
|
||||
|
||||
dm_uid_raw = body.get("dm_open_peer_user_id")
|
||||
dm_dn_raw = body.get("dm_open_display_name")
|
||||
dm_open_peer_user_id = (
|
||||
str(dm_uid_raw if dm_uid_raw is not None else "").strip()[:64]
|
||||
)
|
||||
dm_open_display_name = (
|
||||
str(dm_dn_raw if dm_dn_raw is not None else "").strip()[:200]
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
expires_at = now + float(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS)
|
||||
ctx_id = "ctx_" + secrets.token_urlsafe(24)
|
||||
@@ -4950,6 +5157,8 @@ async def empfang_shell_context_upload(request: Request):
|
||||
"procedure_text": procedure_text,
|
||||
"therapy_autocopy": therapy_autocopy,
|
||||
"procedure_autocopy": procedure_autocopy,
|
||||
"dm_open_peer_user_id": dm_open_peer_user_id,
|
||||
"dm_open_display_name": dm_open_display_name,
|
||||
"expires_at": expires_at,
|
||||
"updated_at": now,
|
||||
}
|
||||
@@ -4995,6 +5204,8 @@ async def empfang_shell_context_me(request: Request):
|
||||
"procedure_text": "",
|
||||
"therapy_autocopy": False,
|
||||
"procedure_autocopy": False,
|
||||
"dm_open_peer_user_id": "",
|
||||
"dm_open_display_name": "",
|
||||
},
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
@@ -5007,6 +5218,13 @@ async def empfang_shell_context_me(request: Request):
|
||||
len(cid),
|
||||
)
|
||||
|
||||
dm_uid_out = str(rec.get("dm_open_peer_user_id") or "")
|
||||
dm_dn_out = str(rec.get("dm_open_display_name") or "")
|
||||
if dm_uid_out:
|
||||
rec["dm_open_peer_user_id"] = ""
|
||||
rec["dm_open_display_name"] = ""
|
||||
_desktop_shell_latest_context_by_user[(pid, uid)] = rec
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
@@ -5015,6 +5233,8 @@ async def empfang_shell_context_me(request: Request):
|
||||
"procedure_text": str(rec.get("procedure_text") or ""),
|
||||
"therapy_autocopy": bool(rec.get("therapy_autocopy")),
|
||||
"procedure_autocopy": bool(rec.get("procedure_autocopy")),
|
||||
"dm_open_peer_user_id": dm_uid_out,
|
||||
"dm_open_display_name": dm_dn_out,
|
||||
},
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user