V5 komplett: Auth, Admin, Federation, Channels, Devices, Cockpit, Profil, Autotext-Fix, Uebersetzer-Fix
Made-with: Cursor
This commit is contained in:
@@ -231,7 +231,8 @@ function boot(){
|
|||||||
f.onload=function(){
|
f.onload=function(){
|
||||||
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
|
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
|
||||||
};
|
};
|
||||||
f.src=URL;
|
var cacheBust = '?_t=' + Date.now();
|
||||||
|
f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now();
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
if(!done){diagnose()}
|
if(!done){diagnose()}
|
||||||
},12000);
|
},12000);
|
||||||
@@ -258,7 +259,7 @@ async function doReload(){
|
|||||||
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
|
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
|
||||||
};
|
};
|
||||||
f.src='about:blank';
|
f.src='about:blank';
|
||||||
setTimeout(function(){f.src=URL},100);
|
setTimeout(function(){f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now()},100);
|
||||||
setTimeout(function(){if(!done)diagnose()},12000);
|
setTimeout(function(){if(!done)diagnose()},12000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ def _draw_module_icon(c: tk.Canvas, key: str):
|
|||||||
fill=fg, outline="")
|
fill=fg, outline="")
|
||||||
|
|
||||||
elif key == "kg":
|
elif key == "kg":
|
||||||
c.create_rectangle(10, 12, s - 10, s - 7, outline=fg, width=2)
|
c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg)
|
||||||
c.create_rectangle(14, 8, s - 14, 15, outline=fg, width=1.5)
|
|
||||||
c.create_line(m, 18, m, s - 10, fill=fg, width=2.5)
|
|
||||||
c.create_line(14, m + 2, s - 14, m + 2, fill=fg, width=2.5)
|
|
||||||
|
|
||||||
elif key == "notizen":
|
elif key == "notizen":
|
||||||
c.create_oval(m - 5, 7, m + 5, 19, outline=fg, width=2)
|
c.create_oval(m - 5, 7, m + 5, 19, outline=fg, width=2)
|
||||||
@@ -250,7 +247,7 @@ class AzaLauncher(tk.Tk):
|
|||||||
|
|
||||||
title_block = tk.Frame(title_row, bg=BG)
|
title_block = tk.Frame(title_row, bg=BG)
|
||||||
title_block.pack(side="left", anchor="w")
|
title_block.pack(side="left", anchor="w")
|
||||||
aza_lbl = tk.Label(title_block, text="AZA",
|
aza_lbl = tk.Label(title_block, text="AzA",
|
||||||
font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG,
|
font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG,
|
||||||
cursor="hand2")
|
cursor="hand2")
|
||||||
aza_lbl.pack(anchor="w")
|
aza_lbl.pack(anchor="w")
|
||||||
@@ -531,10 +528,15 @@ class AzaLauncher(tk.Tk):
|
|||||||
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
|
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
|
||||||
top_row.pack(fill="x", pady=(0, 10))
|
top_row.pack(fill="x", pady=(0, 10))
|
||||||
|
|
||||||
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
|
if mod_key == "kg" and self._logo_img:
|
||||||
bg=icon_color, highlightthickness=0, cursor=_cursor)
|
icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor)
|
||||||
icon_cv.pack(side="left")
|
icon_lbl.image = self._logo_img
|
||||||
_draw_module_icon(icon_cv, mod_key)
|
icon_lbl.pack(side="left")
|
||||||
|
else:
|
||||||
|
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
|
||||||
|
bg=icon_color, highlightthickness=0, cursor=_cursor)
|
||||||
|
icon_cv.pack(side="left")
|
||||||
|
_draw_module_icon(icon_cv, mod_key)
|
||||||
|
|
||||||
lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),
|
lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),
|
||||||
fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor)
|
fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor)
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ def save_paned_positions(positions):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def load_text_font_size(key: str, default: int = 10) -> int:
|
def load_text_font_size(key: str, default: int = 9) -> int:
|
||||||
"""Lädt gespeicherte Schriftgröße für einen bestimmten Text-Widget."""
|
"""Lädt gespeicherte Schriftgröße für einen bestimmten Text-Widget."""
|
||||||
try:
|
try:
|
||||||
path = _font_sizes_config_path()
|
path = _font_sizes_config_path()
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
self._autotext_data.setdefault("eventsSort", "soonest")
|
self._autotext_data.setdefault("eventsSort", "soonest")
|
||||||
self._autotext_data.setdefault("eventsMonthsAhead", 13)
|
self._autotext_data.setdefault("eventsMonthsAhead", 13)
|
||||||
self._autotext_data.setdefault("selectedLanguage", "system")
|
self._autotext_data.setdefault("selectedLanguage", "system")
|
||||||
self._autotext_data.setdefault("user_specialty_default", "dermatology")
|
self._autotext_data.setdefault("user_specialty_default", "")
|
||||||
self._autotext_data.setdefault("user_specialties_selected", [])
|
self._autotext_data.setdefault("user_specialties_selected", [])
|
||||||
self._autotext_data.setdefault("ui_font_delta", -1)
|
self._autotext_data.setdefault("ui_font_delta", -1)
|
||||||
self._autotext_data.setdefault("notizen_open_on_start", True)
|
self._autotext_data.setdefault("notizen_open_on_start", True)
|
||||||
@@ -2881,61 +2881,104 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
dlg.title("Profil bearbeiten")
|
dlg.title("Profil bearbeiten")
|
||||||
dlg.configure(bg="#E8F4FA")
|
dlg.configure(bg="#E8F4FA")
|
||||||
dlg.resizable(True, True)
|
dlg.resizable(True, True)
|
||||||
dlg.geometry("400x560")
|
dlg.geometry("520x820")
|
||||||
dlg.minsize(380, 480)
|
dlg.minsize(460, 680)
|
||||||
dlg.attributes("-topmost", True)
|
dlg.attributes("-topmost", True)
|
||||||
self._register_window(dlg)
|
self._register_window(dlg)
|
||||||
center_window(dlg, 400, 560)
|
center_window(dlg, 520, 820)
|
||||||
|
|
||||||
tk.Label(dlg, text="👤 Profil bearbeiten", font=("Segoe UI", 13, "bold"),
|
tk.Label(dlg, text="\U0001f464 Profil bearbeiten", font=("Segoe UI", 13, "bold"),
|
||||||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8)
|
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8)
|
||||||
|
|
||||||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
scroll_canvas = tk.Canvas(dlg, bg="#E8F4FA", highlightthickness=0)
|
||||||
|
scroll_vsb = ttk.Scrollbar(dlg, orient="vertical", command=scroll_canvas.yview)
|
||||||
|
scroll_canvas.configure(yscrollcommand=scroll_vsb.set)
|
||||||
|
scroll_vsb.pack(side="right", fill="y")
|
||||||
|
scroll_canvas.pack(side="left", fill="both", expand=True)
|
||||||
|
scroll_inner = tk.Frame(scroll_canvas, bg="#E8F4FA")
|
||||||
|
scroll_win = scroll_canvas.create_window((0, 0), window=scroll_inner, anchor="nw")
|
||||||
|
scroll_inner.bind("<Configure>", lambda e: scroll_canvas.configure(scrollregion=scroll_canvas.bbox("all")))
|
||||||
|
scroll_canvas.bind("<Configure>", lambda e: scroll_canvas.itemconfigure(scroll_win, width=e.width))
|
||||||
|
scroll_canvas.bind_all("<MouseWheel>", lambda e: scroll_canvas.yview_scroll(-1 * (e.delta // 120), "units"))
|
||||||
|
dlg.bind("<Destroy>", lambda e: scroll_canvas.unbind_all("<MouseWheel>") if e.widget is dlg else None)
|
||||||
|
|
||||||
|
form = tk.Frame(scroll_inner, bg="#E8F4FA", padx=20, pady=8)
|
||||||
form.pack(fill="x")
|
form.pack(fill="x")
|
||||||
|
|
||||||
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
|
_FLD_FONT = ("Segoe UI", 9)
|
||||||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(8, 0))
|
_LBL_FONT = ("Segoe UI", 9, "bold")
|
||||||
name_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
_PH_COLOR = "#aaa"
|
||||||
name_e.pack(fill="x", ipady=4, pady=(0, 6))
|
_FG_COLOR = "#1a4d6d"
|
||||||
name_e.insert(0, self._user_profile.get("name", ""))
|
|
||||||
|
|
||||||
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
|
def _make_placeholder_entry(parent, placeholder, initial=""):
|
||||||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
e = tk.Entry(parent, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat")
|
||||||
spec_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
e.pack(fill="x", ipady=4, pady=(0, 6))
|
||||||
spec_e.pack(fill="x", ipady=4, pady=(0, 6))
|
|
||||||
spec_e.insert(0, self._user_profile.get("specialty", ""))
|
|
||||||
|
|
||||||
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
|
def _set_ph():
|
||||||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
if not e.get().strip():
|
||||||
clinic_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
e.delete(0, "end")
|
||||||
clinic_e.pack(fill="x", ipady=4, pady=(0, 6))
|
e.insert(0, placeholder)
|
||||||
clinic_e.insert(0, self._user_profile.get("clinic", ""))
|
e.configure(fg=_PH_COLOR)
|
||||||
|
|
||||||
tk.Label(form, text="Code (ZSR/GLN, optional):", font=("Segoe UI", 10, "bold"),
|
def _on_focus_in(ev):
|
||||||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
if e.get() == placeholder and e.cget("fg") == _PH_COLOR:
|
||||||
code_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
e.delete(0, "end")
|
||||||
code_e.pack(fill="x", ipady=4, pady=(0, 6))
|
e.configure(fg=_FG_COLOR)
|
||||||
code_e.insert(0, self._user_profile.get("code", ""))
|
|
||||||
|
|
||||||
tk.Label(form, text="E-Mail (optional):", font=("Segoe UI", 10),
|
def _on_focus_out(ev):
|
||||||
bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(4, 0))
|
_set_ph()
|
||||||
email_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
|
|
||||||
email_e.pack(fill="x", ipady=4, pady=(0, 6))
|
def _get_val():
|
||||||
email_e.insert(0, self._user_profile.get("email", ""))
|
v = e.get().strip()
|
||||||
|
return "" if v == placeholder else v
|
||||||
|
|
||||||
|
e.bind("<FocusIn>", _on_focus_in)
|
||||||
|
e.bind("<FocusOut>", _on_focus_out)
|
||||||
|
e._get_real_value = _get_val
|
||||||
|
|
||||||
|
if initial and initial != placeholder:
|
||||||
|
e.insert(0, initial)
|
||||||
|
e.configure(fg=_FG_COLOR)
|
||||||
|
else:
|
||||||
|
_set_ph()
|
||||||
|
return e
|
||||||
|
|
||||||
|
tk.Label(form, text="Name / Titel:", font=_LBL_FONT,
|
||||||
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(8, 0))
|
||||||
|
_name_initial = self._user_profile.get("name", "")
|
||||||
|
if _name_initial == "Benutzer":
|
||||||
|
_name_initial = ""
|
||||||
|
name_e = _make_placeholder_entry(form, "Ihr Name / Titel eingeben...", _name_initial)
|
||||||
|
|
||||||
|
tk.Label(form, text="Fachrichtung:", font=_LBL_FONT,
|
||||||
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(4, 0))
|
||||||
|
spec_e = _make_placeholder_entry(form, "z.B. Dermatologie", self._user_profile.get("specialty", ""))
|
||||||
|
|
||||||
|
tk.Label(form, text="Praxis / Klinik:", font=_LBL_FONT,
|
||||||
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(4, 0))
|
||||||
|
clinic_e = _make_placeholder_entry(form, "z.B. Hautarztpraxis Winterthur", self._user_profile.get("clinic", ""))
|
||||||
|
|
||||||
|
tk.Label(form, text="Code (ZSR/GLN, optional):", font=_LBL_FONT,
|
||||||
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(4, 0))
|
||||||
|
code_e = _make_placeholder_entry(form, "ZSR / GLN", self._user_profile.get("code", ""))
|
||||||
|
|
||||||
|
tk.Label(form, text="E-Mail:", font=_LBL_FONT,
|
||||||
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(4, 0))
|
||||||
|
email_e = _make_placeholder_entry(form, "praxis@beispiel.ch", self._user_profile.get("email", ""))
|
||||||
|
|
||||||
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||||||
sep.pack(fill="x", pady=(8, 6))
|
sep.pack(fill="x", pady=(8, 6))
|
||||||
|
|
||||||
tk.Label(form, text="🔑 Passwort ändern (optional):", font=("Segoe UI", 10, "bold"),
|
tk.Label(form, text="\U0001f511 Passwort \u00e4ndern (optional):", font=_LBL_FONT,
|
||||||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(2, 0))
|
||||||
tk.Label(form, text="Leer lassen, um das Passwort beizubehalten.",
|
tk.Label(form, text="Leer lassen, um das Passwort beizubehalten.",
|
||||||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
|
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
|
||||||
|
|
||||||
pw_old_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
pw_old_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||||||
relief="flat", bd=0, show="")
|
relief="flat", bd=0, show="")
|
||||||
pw_new_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
pw_new_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||||||
relief="flat", bd=0, show="")
|
relief="flat", bd=0, show="")
|
||||||
pw_confirm_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
pw_confirm_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||||||
relief="flat", bd=0, show="")
|
relief="flat", bd=0, show="")
|
||||||
|
|
||||||
if self._user_profile.get("password_hash"):
|
if self._user_profile.get("password_hash"):
|
||||||
@@ -2951,7 +2994,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4))
|
pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||||||
|
|
||||||
def do_save():
|
def do_save():
|
||||||
name = name_e.get().strip()
|
name = name_e._get_real_value() if hasattr(name_e, '_get_real_value') else name_e.get().strip()
|
||||||
if not name:
|
if not name:
|
||||||
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
|
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
|
||||||
return
|
return
|
||||||
@@ -2967,18 +3010,19 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
messagebox.showwarning("Zu kurz", "Das neue Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
messagebox.showwarning("Zu kurz", "Das neue Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
||||||
return
|
return
|
||||||
if new_pw != new_pw_confirm:
|
if new_pw != new_pw_confirm:
|
||||||
messagebox.showerror("Fehler", "Die neuen Passwörter stimmen nicht überein.", parent=dlg)
|
messagebox.showerror("Fehler", "Die neuen Passw\u00f6rter stimmen nicht \u00fcberein.", parent=dlg)
|
||||||
return
|
return
|
||||||
pw_hash = self._hash_password(new_pw)
|
pw_hash = self._hash_password(new_pw)
|
||||||
else:
|
else:
|
||||||
pw_hash = old_hash
|
pw_hash = old_hash
|
||||||
|
|
||||||
|
_gv = lambda e: e._get_real_value() if hasattr(e, '_get_real_value') else e.get().strip()
|
||||||
updated = {
|
updated = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"specialty": spec_e.get().strip(),
|
"specialty": _gv(spec_e),
|
||||||
"clinic": clinic_e.get().strip(),
|
"clinic": _gv(clinic_e),
|
||||||
"code": code_e.get().strip(),
|
"code": _gv(code_e),
|
||||||
"email": email_e.get().strip(),
|
"email": _gv(email_e),
|
||||||
"password_hash": pw_hash,
|
"password_hash": pw_hash,
|
||||||
}
|
}
|
||||||
for k in ("totp_secret_enc", "totp_active", "backup_codes"):
|
for k in ("totp_secret_enc", "totp_active", "backup_codes"):
|
||||||
@@ -2989,9 +3033,90 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
self.set_status(f"Profil gespeichert: {name}")
|
self.set_status(f"Profil gespeichert: {name}")
|
||||||
dlg.destroy()
|
dlg.destroy()
|
||||||
|
|
||||||
btn_row = tk.Frame(dlg, bg="#E8F4FA")
|
# --- Lizenz / Einladung ---
|
||||||
|
lic_sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||||||
|
lic_sep.pack(fill="x", pady=(10, 6))
|
||||||
|
tk.Label(form, text="\U0001f4cb Lizenz & Einladung", font=("Segoe UI", 10, "bold"),
|
||||||
|
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 4))
|
||||||
|
|
||||||
|
_lic_key = (self._user_profile.get("license_key") or "").strip()
|
||||||
|
_lic_email = (self._user_profile.get("email") or "").strip()
|
||||||
|
if _lic_key:
|
||||||
|
tk.Label(form, text="Lizenznummer:", font=("Segoe UI", 9),
|
||||||
|
bg="#E8F4FA", fg="#5B8DB3").pack(anchor="w")
|
||||||
|
_lic_lbl = tk.Label(form, text=_lic_key, font=("Consolas", 9),
|
||||||
|
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||||||
|
padx=6, pady=2, cursor="hand2", anchor="w")
|
||||||
|
_lic_lbl.pack(fill="x", pady=(0, 6))
|
||||||
|
_lic_lbl.bind("<Button-1>", lambda e: (
|
||||||
|
dlg.clipboard_clear(), dlg.clipboard_append(_lic_key),
|
||||||
|
self.set_status("Lizenznummer kopiert.")))
|
||||||
|
|
||||||
|
_invite_frame = tk.Frame(form, bg="#E8F4FA")
|
||||||
|
_invite_frame.pack(fill="x", pady=(0, 4))
|
||||||
|
_invite_label = tk.Label(_invite_frame, text="Einladungscode:", font=("Segoe UI", 9),
|
||||||
|
bg="#E8F4FA", fg="#5B8DB3")
|
||||||
|
_invite_label.pack(anchor="w")
|
||||||
|
_invite_val = tk.Label(_invite_frame, text="Wird geladen...", font=("Consolas", 9),
|
||||||
|
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||||||
|
padx=6, pady=2, anchor="w")
|
||||||
|
_invite_val.pack(fill="x")
|
||||||
|
|
||||||
|
_link_frame = tk.Frame(form, bg="#E8F4FA")
|
||||||
|
_link_frame.pack(fill="x", pady=(0, 6))
|
||||||
|
_link_label = tk.Label(_link_frame, text="Einladungslink:", font=("Segoe UI", 9),
|
||||||
|
bg="#E8F4FA", fg="#5B8DB3")
|
||||||
|
_link_label.pack(anchor="w")
|
||||||
|
_link_val = tk.Label(_link_frame, text="", font=("Segoe UI", 8),
|
||||||
|
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||||||
|
padx=6, pady=2, anchor="w", wraplength=440, justify="left")
|
||||||
|
_link_val.pack(fill="x")
|
||||||
|
|
||||||
|
_link_btn_row = tk.Frame(form, bg="#E8F4FA")
|
||||||
|
_link_btn_row.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
def _copy_invite_link():
|
||||||
|
text = _link_val.cget("text")
|
||||||
|
if text:
|
||||||
|
dlg.clipboard_clear()
|
||||||
|
dlg.clipboard_append(text)
|
||||||
|
self.set_status("Einladungslink kopiert.")
|
||||||
|
|
||||||
|
tk.Button(_link_btn_row, text="Link kopieren", font=("Segoe UI", 9),
|
||||||
|
bg="#e8f0f8", fg="#2a5a8a", relief="flat", padx=10, pady=3,
|
||||||
|
cursor="hand2", command=_copy_invite_link).pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
|
def _load_invite_info():
|
||||||
|
try:
|
||||||
|
bu = self.get_backend_url()
|
||||||
|
bt = self.get_backend_token()
|
||||||
|
r = requests.get(f"{bu}/empfang/practice/info",
|
||||||
|
headers={"X-API-Token": bt}, timeout=5)
|
||||||
|
if r.status_code == 200:
|
||||||
|
d = r.json()
|
||||||
|
code = d.get("invite_code", "")
|
||||||
|
pname = d.get("practice_name", "")
|
||||||
|
if code:
|
||||||
|
link = f"https://empfang.aza-medwork.ch/?invite={code}"
|
||||||
|
self.after(0, lambda: _invite_val.configure(text=code))
|
||||||
|
self.after(0, lambda: _link_val.configure(text=link))
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: _invite_val.configure(text=""))
|
||||||
|
self.after(0, lambda: _link_val.configure(text=""))
|
||||||
|
self.after(0, lambda: _invite_frame.pack_forget())
|
||||||
|
self.after(0, lambda: _link_frame.pack_forget())
|
||||||
|
self.after(0, lambda: _link_btn_row.pack_forget())
|
||||||
|
except Exception:
|
||||||
|
self.after(0, lambda: _invite_frame.pack_forget())
|
||||||
|
self.after(0, lambda: _link_frame.pack_forget())
|
||||||
|
self.after(0, lambda: _link_btn_row.pack_forget())
|
||||||
|
|
||||||
|
threading.Thread(target=_load_invite_info, daemon=True).start()
|
||||||
|
|
||||||
|
# --- Buttons ---
|
||||||
|
btn_row = tk.Frame(scroll_inner, bg="#E8F4FA")
|
||||||
btn_row.pack(pady=(12, 10))
|
btn_row.pack(pady=(12, 10))
|
||||||
tk.Button(btn_row, text="💾 Speichern", font=("Segoe UI", 11, "bold"),
|
tk.Button(btn_row, text="\U0001f4be Speichern", font=("Segoe UI", 11, "bold"),
|
||||||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||||||
relief="flat", padx=20, pady=6, cursor="hand2",
|
relief="flat", padx=20, pady=6, cursor="hand2",
|
||||||
command=do_save).pack(side="left", padx=8)
|
command=do_save).pack(side="left", padx=8)
|
||||||
@@ -3001,9 +3126,9 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
command=dlg.destroy).pack(side="left", padx=8)
|
command=dlg.destroy).pack(side="left", padx=8)
|
||||||
|
|
||||||
if is_2fa_enabled():
|
if is_2fa_enabled():
|
||||||
sep2 = tk.Frame(dlg, bg="#B9ECFA", height=1)
|
sep2 = tk.Frame(scroll_inner, bg="#B9ECFA", height=1)
|
||||||
sep2.pack(fill="x", padx=20, pady=(4, 4))
|
sep2.pack(fill="x", padx=20, pady=(4, 4))
|
||||||
tfa_frame = tk.Frame(dlg, bg="#E8F4FA", padx=20)
|
tfa_frame = tk.Frame(scroll_inner, bg="#E8F4FA", padx=20)
|
||||||
tfa_frame.pack(fill="x")
|
tfa_frame.pack(fill="x")
|
||||||
is_active = self._user_profile.get("totp_active", False)
|
is_active = self._user_profile.get("totp_active", False)
|
||||||
status_text = "2FA aktiv" if is_active else "2FA nicht aktiv"
|
status_text = "2FA aktiv" if is_active else "2FA nicht aktiv"
|
||||||
@@ -4001,22 +4126,24 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
|
|||||||
dlg.transient(self)
|
dlg.transient(self)
|
||||||
dlg.grab_set()
|
dlg.grab_set()
|
||||||
dlg.configure(bg="#F2F8FC")
|
dlg.configure(bg="#F2F8FC")
|
||||||
|
dlg.geometry("520x620")
|
||||||
dlg.minsize(420, 500)
|
dlg.minsize(420, 500)
|
||||||
add_resize_grip(dlg, 420, 500)
|
add_resize_grip(dlg, 420, 500)
|
||||||
|
center_window(dlg, 520, 620)
|
||||||
body = tk.Frame(dlg, bg="#F2F8FC", padx=12, pady=12)
|
body = tk.Frame(dlg, bg="#F2F8FC", padx=12, pady=12)
|
||||||
body.pack(fill="both", expand=True)
|
body.pack(fill="both", expand=True)
|
||||||
tk.Label(body, text="Primäres Fachgebiet (Erststart)", bg="#F2F8FC", fg="#1a4d6d", font=("Segoe UI", 11, "bold")).pack(anchor="w")
|
tk.Label(body, text="Primäres Fachgebiet (Erststart)", bg="#F2F8FC", fg="#1a4d6d", font=("Segoe UI", 11, "bold")).pack(anchor="w")
|
||||||
primary_var = tk.StringVar(value="dermatology")
|
primary_var = tk.StringVar(value="")
|
||||||
label_to_key = {label: key for key, label in catalog}
|
label_to_key = {label: key for key, label in catalog}
|
||||||
key_to_label = {key: label for key, label in catalog}
|
key_to_label = {key: label for key, label in catalog}
|
||||||
combo_val = tk.StringVar(value=key_to_label.get("dermatology", "Dermatologie"))
|
combo_val = tk.StringVar(value="")
|
||||||
combo = ttk.Combobox(body, values=[label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
|
combo = ttk.Combobox(body, values=[""] + [label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
|
||||||
combo.pack(anchor="w", pady=(6, 10))
|
combo.pack(anchor="w", pady=(6, 10))
|
||||||
|
|
||||||
tk.Label(body, text="Weitere Fachgebiete (optional)", bg="#F2F8FC", fg="#1a4d6d").pack(anchor="w")
|
tk.Label(body, text="Weitere Fachgebiete (optional)", bg="#F2F8FC", fg="#1a4d6d").pack(anchor="w")
|
||||||
vars_map = {}
|
vars_map = {}
|
||||||
for key, label in catalog:
|
for key, label in catalog:
|
||||||
v = tk.BooleanVar(value=(key == "dermatology"))
|
v = tk.BooleanVar(value=False)
|
||||||
vars_map[key] = v
|
vars_map[key] = v
|
||||||
tk.Checkbutton(body, text=label, variable=v, bg="#F2F8FC", activebackground="#F2F8FC", selectcolor="#E7F4FA").pack(anchor="w")
|
tk.Checkbutton(body, text=label, variable=v, bg="#F2F8FC", activebackground="#F2F8FC", selectcolor="#E7F4FA").pack(anchor="w")
|
||||||
|
|
||||||
@@ -5185,7 +5312,7 @@ WICHTIG unbedingt einhalten:
|
|||||||
dlg.resizable(True, True)
|
dlg.resizable(True, True)
|
||||||
self._register_window(dlg)
|
self._register_window(dlg)
|
||||||
|
|
||||||
_empfang_font_size = [load_text_font_size("empfang_dlg", 10)]
|
_empfang_font_size = [load_text_font_size("empfang_dlg", 9)]
|
||||||
_empfang_text_widgets: list = []
|
_empfang_text_widgets: list = []
|
||||||
|
|
||||||
def _save_prefs():
|
def _save_prefs():
|
||||||
@@ -7386,11 +7513,12 @@ WICHTIG unbedingt einhalten:
|
|||||||
dlg.title("KI-Einwilligung erforderlich")
|
dlg.title("KI-Einwilligung erforderlich")
|
||||||
dlg.transient(self)
|
dlg.transient(self)
|
||||||
dlg.grab_set()
|
dlg.grab_set()
|
||||||
dlg.geometry("680x520")
|
dlg.geometry("920x720")
|
||||||
dlg.minsize(500, 400)
|
dlg.minsize(700, 550)
|
||||||
dlg.attributes("-topmost", True)
|
dlg.attributes("-topmost", True)
|
||||||
self._register_window(dlg)
|
self._register_window(dlg)
|
||||||
add_resize_grip(dlg, 500, 400)
|
add_resize_grip(dlg, 700, 550)
|
||||||
|
center_window(dlg, 920, 720)
|
||||||
|
|
||||||
frame = ttk.Frame(dlg, padding=12)
|
frame = ttk.Frame(dlg, padding=12)
|
||||||
frame.pack(fill="both", expand=True)
|
frame.pack(fill="both", expand=True)
|
||||||
@@ -8769,7 +8897,11 @@ WICHTIG:
|
|||||||
text_widget.bind("<FocusIn>", on_focus_in, add="+")
|
text_widget.bind("<FocusIn>", on_focus_in, add="+")
|
||||||
text_widget.bind("<FocusOut>", on_focus_out, add="+")
|
text_widget.bind("<FocusOut>", on_focus_out, add="+")
|
||||||
|
|
||||||
|
_last_expansion = [0.0, ""]
|
||||||
|
|
||||||
def on_keyrelease(event):
|
def on_keyrelease(event):
|
||||||
|
if getattr(self, "_autotext_injecting", [False])[0]:
|
||||||
|
return
|
||||||
if not getattr(self, "_autotext_data", {}).get("enabled", True):
|
if not getattr(self, "_autotext_data", {}).get("enabled", True):
|
||||||
return
|
return
|
||||||
entries = (self._autotext_data.get("entries") or {})
|
entries = (self._autotext_data.get("entries") or {})
|
||||||
@@ -8792,7 +8924,12 @@ WICHTIG:
|
|||||||
word = text_before[word_start:word_end]
|
word = text_before[word_start:word_end]
|
||||||
if not word or word not in entries:
|
if not word or word not in entries:
|
||||||
return
|
return
|
||||||
|
now = time.time()
|
||||||
|
if _last_expansion[1] == word and now - _last_expansion[0] < 1.0:
|
||||||
|
return
|
||||||
expansion = entries[word]
|
expansion = entries[word]
|
||||||
|
_last_expansion[0] = now
|
||||||
|
_last_expansion[1] = word
|
||||||
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
|
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
|
||||||
text_widget.delete(start_idx, insert)
|
text_widget.delete(start_idx, insert)
|
||||||
text_widget.insert(start_idx, expansion + last_char)
|
text_widget.insert(start_idx, expansion + last_char)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# AZA – Master Handover / Operational Runbook
|
# AZA – Master Handover / Operational Runbook
|
||||||
|
|
||||||
## Arbeitsmodus / Regeln
|
## Arbeitsmodus / Regeln (VERBINDLICH)
|
||||||
|
|
||||||
User bastelt nicht; nur Composer-Patches (meist Opus) oder 1 exakter Command mit Pfad.
|
User bastelt nicht; nur Composer-Patches (meist Opus) oder 1 exakter Command mit Pfad.
|
||||||
|
|
||||||
@@ -8,10 +8,76 @@ User bastelt nicht; nur Composer-Patches (meist Opus) oder 1 exakter Command mit
|
|||||||
- User fuehrt nur vorgegebene Commands aus, keine manuellen Edits.
|
- User fuehrt nur vorgegebene Commands aus, keine manuellen Edits.
|
||||||
- Jede Aenderung in 1 Patch, kein schrittweises Anleiten.
|
- Jede Aenderung in 1 Patch, kein schrittweises Anleiten.
|
||||||
- Keine risky Refactors – immer minimal und sicher.
|
- Keine risky Refactors – immer minimal und sicher.
|
||||||
|
- KEINE Bastelloesungen, KEINE Prototypisierung, KEINE Zwischenloesungen.
|
||||||
|
- KEINE localStorage-Geschaeftsdaten als Zielbild.
|
||||||
|
- KEINE vagen „spaeter besser"-Loesungen.
|
||||||
|
- KEINE Diffs als Hauptlieferung – nur vollstaendige fertige Dateien.
|
||||||
|
- Immer dazuschreiben WO etwas auszufuehren ist: Browser / Windows PowerShell / Hetzner SSH / Composer.
|
||||||
|
- Root-cause-first bei jedem Problem.
|
||||||
|
- Nur Schritte empfehlen, die zur Live-Architektur passen.
|
||||||
|
|
||||||
## AKTUELLE PROJEKTPHASE (Stand 2026-04-12)
|
## Server-Deploy-Realitaet (VERBINDLICH)
|
||||||
|
|
||||||
**Phase:** Device-/Seat-Logik V1 implementiert. Backup-Konzept + Deinstallations-UX als naechste Hauptbloecke.
|
Hetzner `/root/aza-app` ist KEIN Git-Repository.
|
||||||
|
Server-Updates laufen real ueber:
|
||||||
|
1. Lokal aendern (Composer/Max)
|
||||||
|
2. Per `scp` auf Hetzner hochkopieren
|
||||||
|
3. Auf Hetzner: `cd /root/aza-app/deploy && docker compose up --build -d`
|
||||||
|
|
||||||
|
NICHT `git pull` auf Hetzner annehmen. Das funktioniert dort nicht.
|
||||||
|
|
||||||
|
## AKTUELLE PROJEKTPHASE (Stand 2026-04-18)
|
||||||
|
|
||||||
|
**Phase:** Praxis-Chat V5 live (Auth + Sessions + Tasks + Admin + Devices + Channels + Federation).
|
||||||
|
Admin-Panel im Browser eingebaut. Naechste Bloecke: Admin real pruefen, Empfangs-Huelle NaN-Bug, Uebersetzer-Bug.
|
||||||
|
|
||||||
|
## BEKANNTE REGRESSIONEN + KORREKTUREN
|
||||||
|
|
||||||
|
### Autotext-Regression April 2026 (GEFIXT + EINGEFROREN)
|
||||||
|
|
||||||
|
**Root Cause:** Race Condition zwischen zwei gleichzeitig aktiven Autotext-Systemen:
|
||||||
|
1. In-App-Autotext (`on_keyrelease` auf Tkinter Text-Widgets, `_bind_autotext()`)
|
||||||
|
2. Globaler Autotext-Listener (pynput `_run_global_autotext_listener()`)
|
||||||
|
|
||||||
|
Der Fokusstatus (`_autotext_focus_in_app`) wird per PID-Check alle 1000ms aktualisiert.
|
||||||
|
In dem Zeitfenster konnten beide Systeme feuern → doppelte Einfuegung.
|
||||||
|
|
||||||
|
**Symptome:**
|
||||||
|
- Doppelte Autotext-Einfuegung in AZA-Textfeldern
|
||||||
|
- Teils unzuverlaessiges Verhalten in externen Programmen
|
||||||
|
|
||||||
|
**Funktionierende Korrektur (basis14.py, `_bind_autotext()`):**
|
||||||
|
- `_autotext_injecting`-Check am Anfang von `on_keyrelease`: ueberspringt wenn der
|
||||||
|
globale Listener gerade injiziert
|
||||||
|
- Deduplizierung: wenn dasselbe Wort innerhalb von 1 Sekunde erneut expandiert werden
|
||||||
|
soll, wird es uebersprungen (`_last_expansion` mit Zeitstempel + Wort)
|
||||||
|
- Kein grosser Umbau, nur minimale Absicherung
|
||||||
|
|
||||||
|
**Do-Not-Break-Regeln (VERBINDLICH):**
|
||||||
|
- `_run_global_autotext_listener()` NICHT aendern (Freeze seit Maerz 2026)
|
||||||
|
- `_bind_autotext()` NICHT aendern ausser bei reproduzierbarem Bug
|
||||||
|
- `_periodic_focus_check()` NICHT aendern
|
||||||
|
- `_check_autotext_focus_out()` NICHT aendern
|
||||||
|
- Die `_last_expansion`-Deduplizierung NICHT entfernen
|
||||||
|
- Den `_autotext_injecting`-Check in `on_keyrelease` NICHT entfernen
|
||||||
|
- KEINE Disk-I/O in on_press/on_release
|
||||||
|
- KEINE Aenderung der Timing-Konstanten (REPLACE_DELAY, 0.04s/0.01s, etc.)
|
||||||
|
- Bei kuenftigen Aenderungen an basis14.py: Autotext-Bereich (Zeilen ~8825-9082) explizit
|
||||||
|
NICHT anfassen, ausser bei reproduzierbarem Bug mit klarer Root Cause
|
||||||
|
|
||||||
|
## OFFENE REALE BUGS (Stand 2026-04-18)
|
||||||
|
|
||||||
|
1. **Empfangs-Huelle: Schriftgroesse NaN** (GEFIXT, Deploy ausstehend)
|
||||||
|
- Root Cause: Huelle laedt alte empfang.html. Neue Version hat robustes parseInt + kein uiScale.
|
||||||
|
- Fix: neue empfang.html per scp deployen
|
||||||
|
|
||||||
|
2. **Uebersetzer: Zielsprache wird nicht eingehalten** (GEFIXT, Neustart noetig)
|
||||||
|
- Root Cause: `get_lang_codes()` fiel bei fehlendem ` – ` im Combo-Wert auf `"en"` zurueck
|
||||||
|
- Fix: 3 Stellen in translate.py gefixt, Fallback nutzt gespeicherte Sprache
|
||||||
|
|
||||||
|
3. **`An Empfang senden` (`basis14.py`): noch nicht professionell**
|
||||||
|
- ~1100 Zeilen `_send_to_empfang()` – Hochrisikobereich
|
||||||
|
- Offen: Auto-Integration medizinischer Inhalte, Bilder, Chatfluss, Aktionsleiste
|
||||||
|
|
||||||
## VERBINDLICHE PROJEKTKONTINUITAET (ab 2026-04-12)
|
## VERBINDLICHE PROJEKTKONTINUITAET (ab 2026-04-12)
|
||||||
|
|
||||||
@@ -218,40 +284,190 @@ kurze Antworten, kein voller Hauptclient).
|
|||||||
|
|
||||||
Festlegung: Mobile spaeter lieber als echte App, nicht als dauerhafte Browser-Notloesung.
|
Festlegung: Mobile spaeter lieber als echte App, nicht als dauerhafte Browser-Notloesung.
|
||||||
|
|
||||||
#### 6.11 Aktueller Stand vs. Zielarchitektur
|
#### 6.11 Praxis-zu-Praxis-Kopplung (VERBINDLICH, 2026-04-18)
|
||||||
|
|
||||||
**Was schon da ist (V1):**
|
Interne Kurzbezeichnung: **AZA Praxis-Federation**
|
||||||
- Benutzer-Sync via Backend (`empfang_users.json`)
|
|
||||||
- Thread-basierter Chat (thread_id, reply_to)
|
|
||||||
- Aufgaben-Panel (localStorage, user-scoped)
|
|
||||||
- 3-Panel-Layout im Browser-Empfang
|
|
||||||
- Ton-/Benachrichtigungssystem
|
|
||||||
- Empfangs-Desktop-Huelle
|
|
||||||
|
|
||||||
**Was noch fehlt fuer V2:**
|
**Grundprinzip:** Praxen sind standardmaessig vollstaendig getrennt.
|
||||||
- practice_id in allen Entitaeten
|
Eine Verbindung entsteht NUR durch explizite beidseitige Zustimmung.
|
||||||
- Echte Authentifizierung (JWT/Session)
|
|
||||||
- Serverseitige Kanalstruktur
|
**Ablauf:**
|
||||||
- Serverseitige Aufgaben (statt localStorage)
|
1. Admin Praxis A erzeugt Verbindungseinladung (Einmal-Code, 48h gueltig)
|
||||||
- Geraeteverwaltung fuer Praxis-Admin
|
2. Admin Praxis B gibt Code ein und bestaetigt
|
||||||
- QR-Code-Kopplung fuer Mobile
|
3. Serverseitig: `PracticeConnection`-Objekt wird angelegt
|
||||||
|
4. Definierte externe Kommunikationsraeume werden freigeschaltet
|
||||||
|
5. Jeder Admin kann die Verbindung jederzeit trennen
|
||||||
|
|
||||||
|
**Datenmodell `PracticeConnection`:**
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `connection_id` | UUID | Eindeutige Verbindung |
|
||||||
|
| `practice_a_id` | UUID | Initiierende Praxis |
|
||||||
|
| `practice_b_id` | UUID | Annehmende Praxis |
|
||||||
|
| `status` | enum | pending / active / revoked |
|
||||||
|
| `created_by` | user_id | Admin der die Einladung erstellt hat |
|
||||||
|
| `accepted_by` | user_id | Admin der angenommen hat |
|
||||||
|
| `created_at` | timestamp | Erstellzeitpunkt |
|
||||||
|
| `accepted_at` | timestamp | Annahmezeitpunkt |
|
||||||
|
| `revoked_at` | timestamp | Trennzeitpunkt (optional) |
|
||||||
|
| `shared_channels` | list | Freigegebene Kanaltypen |
|
||||||
|
|
||||||
|
**Externe Kanalstruktur bei Kopplung:**
|
||||||
|
- Allgemeiner externer Kanal (Praxis A ↔ Praxis B)
|
||||||
|
- Optional: Aerzte ↔ Aerzte (nur Aerzte beider Praxen)
|
||||||
|
- Optional: Sekretariat ↔ Sekretariat
|
||||||
|
- Jeder externe Kanal wird vom jeweiligen Admin freigegeben
|
||||||
|
|
||||||
|
**Sicherheitsregeln:**
|
||||||
|
- Externe Nachrichten sind IMMER als extern markiert
|
||||||
|
- Externe Benutzer sehen NUR den freigegebenen Kanal, NICHT interne Daten
|
||||||
|
- Trennung sofort wirksam (kein Nachlauf)
|
||||||
|
- Verbindungshistorie im Audit-Log
|
||||||
|
|
||||||
|
#### 6.12 Admin-Benutzerverwaltung (VERBINDLICH, 2026-04-18)
|
||||||
|
|
||||||
|
**Benutzer-Objekt (Mindestfelder):**
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | UUID | Eindeutiger Benutzer |
|
||||||
|
| `practice_id` | UUID | Praxis-Zugehoerigkeit |
|
||||||
|
| `display_name` | string | Anzeigename |
|
||||||
|
| `email` | string | E-Mail (optional, fuer Passwort-Reset) |
|
||||||
|
| `role` | enum | admin / arzt / mpa / empfang |
|
||||||
|
| `status` | enum | active / deactivated / deleted |
|
||||||
|
| `pw_hash` | string | PBKDF2-SHA256 |
|
||||||
|
| `pw_salt` | string | Zufaelliger Salt |
|
||||||
|
| `created_by` | user_id | Wer hat den Account angelegt |
|
||||||
|
| `created_at` | timestamp | Erstellzeitpunkt |
|
||||||
|
| `last_login` | timestamp | Letzter Login |
|
||||||
|
| `deactivated_at` | timestamp | Deaktivierungszeitpunkt (optional) |
|
||||||
|
|
||||||
|
**Admin-Aktionen:**
|
||||||
|
- Benutzer anlegen (mit Name, Rolle, optionalem Passwort)
|
||||||
|
- Benutzer deaktivieren (Login gesperrt, Daten bleiben)
|
||||||
|
- Benutzer reaktivieren
|
||||||
|
- Benutzer endgueltig loeschen (DSGVO)
|
||||||
|
- Rolle aendern (arzt ↔ mpa ↔ empfang, Admin nur durch anderen Admin)
|
||||||
|
- Passwort zuruecksetzen (erzeugt temporaeres Passwort)
|
||||||
|
- Alle Sitzungen eines Benutzers beenden
|
||||||
|
|
||||||
|
**Nachvollziehbarkeit:**
|
||||||
|
- `created_by` zeigt wer den Benutzer angelegt hat
|
||||||
|
- Rollenaenderungen im Audit-Log
|
||||||
|
- Deaktivierung/Loeschung im Audit-Log
|
||||||
|
|
||||||
|
#### 6.13 Admin-Geraeteverwaltung (VERBINDLICH, 2026-04-18)
|
||||||
|
|
||||||
|
**Device-Objekt (Mindestfelder):**
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `device_id` | UUID | Eindeutiges Geraet |
|
||||||
|
| `user_id` | UUID | Zugehoeriger Benutzer |
|
||||||
|
| `practice_id` | UUID | Zugehoerige Praxis |
|
||||||
|
| `device_name` | string | z.B. "Praxis-PC Empfang", "iPhone" |
|
||||||
|
| `platform` | string | Windows / macOS / iOS / Android / Browser |
|
||||||
|
| `device_type` | enum | desktop / browser / mobile / tablet |
|
||||||
|
| `user_agent` | string | Browser-/App-Kennung |
|
||||||
|
| `last_active` | timestamp | Letzter API-Zugriff |
|
||||||
|
| `first_seen` | timestamp | Erstmalige Registrierung |
|
||||||
|
| `trust_status` | enum | trusted / pending / blocked |
|
||||||
|
| `ip_last` | string | Letzte IP-Adresse |
|
||||||
|
|
||||||
|
**Admin sieht pro Benutzer:**
|
||||||
|
- Alle registrierten Geraete
|
||||||
|
- Typ (Desktop-Icon, Browser-Icon, Handy-Icon)
|
||||||
|
- Letzter Zugriff (relativ: "vor 2 Stunden", "vor 3 Tagen")
|
||||||
|
- Status-Badge (aktiv / verdaechtig / gesperrt)
|
||||||
|
|
||||||
|
**Admin-Aktionen:**
|
||||||
|
- Geraet als vertrauenswuerdig markieren
|
||||||
|
- Geraet sperren (sofort, alle Sitzungen werden beendet)
|
||||||
|
- Geraet loeschen (aus der Liste entfernen)
|
||||||
|
- Alle Sitzungen auf einem Geraet beenden
|
||||||
|
- Erneute Anmeldung erzwingen
|
||||||
|
|
||||||
|
**Automatische Erkennung:**
|
||||||
|
- Bei jedem Login: device_id wird aus User-Agent + fingerprint abgeleitet
|
||||||
|
(oder Cookie-basiert fuer Browser)
|
||||||
|
- Neues Geraet: Admin erhaelt optional Benachrichtigung
|
||||||
|
- Zu viele Geraete: Warnung an Admin
|
||||||
|
|
||||||
|
#### 6.14 UI-Sichtbarkeit nach Rolle
|
||||||
|
|
||||||
|
**Admin sieht:**
|
||||||
|
- Praxis-Einstellungen (Name, Einladungscode, Admin-E-Mail)
|
||||||
|
- Alle Benutzer mit Rollen + Status + letztem Login
|
||||||
|
- Alle Geraete aller Benutzer
|
||||||
|
- Gekoppelte Praxen + Verbindungsstatus
|
||||||
|
- Alle internen + externen Kanaele
|
||||||
- Audit-Log
|
- Audit-Log
|
||||||
|
|
||||||
#### 6.12 Umsetzungsphasen
|
**Arzt / MPA / Empfang sieht:**
|
||||||
|
- Eigene Praxis (Name)
|
||||||
|
- Eigene Kanaele (interner Allgemein + Rollen-Kanal + Direktchats)
|
||||||
|
- Eigene Aufgaben + an eigene Rolle zugewiesene Aufgaben
|
||||||
|
- Freigegebene externe Kanaele (falls Praxis-Kopplung aktiv)
|
||||||
|
- Eigene Geraete (nur eigene, nicht die anderer Benutzer)
|
||||||
|
|
||||||
**Phase 1 (kurzfristig – aktuell):**
|
**Externer Praxis-Kontakt sieht:**
|
||||||
Frontend-Layout, Benutzer-Sync, Chat-Threads. Kein Backend-Umbau.
|
- NUR den freigegebenen externen Kanal
|
||||||
Einzelpraxis-Betrieb reicht. practice_id wird als Konzept vorbereitet,
|
- Keine internen Daten, Benutzer, Aufgaben der anderen Praxis
|
||||||
aber noch nicht erzwungen.
|
|
||||||
|
|
||||||
**Phase 2 (mittelfristig):**
|
#### 6.15 Aktueller Stand (nach V4-Deploy, 2026-04-18)
|
||||||
Backend: practice_id + user_id + JWT-Auth einfuehren. Kanalstruktur serverseitig.
|
|
||||||
Aufgaben serverseitig. Geraeteverwaltung. Admin-Panel fuer Practice Admin.
|
|
||||||
Presence/Heartbeat. Invite-Links.
|
|
||||||
|
|
||||||
**Phase 3 (spaeter):**
|
**Was jetzt implementiert ist:**
|
||||||
Multi-Tenant produktiv (mehrere Praxen). WebSocket statt Polling. Mobile-App.
|
- Serverseitige Auth: PBKDF2 + Session-Cookie (empfang_routes.py V4)
|
||||||
QR-Code-Kopplung. 2FA. Verschluesselte Speicherung. Externe Praxis-Verbindungen.
|
- practice_id in allen Nachrichten + Benutzern + Aufgaben
|
||||||
|
- Login / Setup / Register mit Einladungscode
|
||||||
|
- Rollen: admin, arzt, mpa, empfang
|
||||||
|
- Serverseitige Aufgaben (GET/POST/DELETE /tasks)
|
||||||
|
- Browser-Empfang mit Login-Overlay, Server-only-Daten
|
||||||
|
- Praxisname + Admin-E-Mail beim Setup
|
||||||
|
- Einladungscode kopieren + erneuern (Admin)
|
||||||
|
- 3-Panel-Layout (Sidebar, Chat, Aufgaben)
|
||||||
|
- Ton-/Benachrichtigungssystem
|
||||||
|
- Empfangs-Desktop-Huelle
|
||||||
|
- Caddy Rewrite (empfang.aza-medwork.ch ohne /empfang/)
|
||||||
|
|
||||||
|
**Was als naechstes fehlt:**
|
||||||
|
- Serverseitige Kanalstruktur (Allgemein, Aerzte, MPA statt flacher Thread-Liste)
|
||||||
|
- Device-Tracking (device_id bei jedem Login)
|
||||||
|
- Admin-Panel im Browser (Benutzer verwalten, Geraete sehen)
|
||||||
|
- Praxis-zu-Praxis-Kopplung (Federation)
|
||||||
|
- Audit-Log
|
||||||
|
- QR-Code-Kopplung fuer Mobile
|
||||||
|
|
||||||
|
#### 6.16 Umsetzungsphasen (aktualisiert 2026-04-18)
|
||||||
|
|
||||||
|
**Phase 1 (ERLEDIGT):**
|
||||||
|
Backend V4 mit Auth + practice_id + Sessions + Tasks.
|
||||||
|
Browser-Empfang mit Login, Server-only-Daten, 3-Panel-Layout.
|
||||||
|
Einzelpraxis-Betrieb produktiv moeglich.
|
||||||
|
|
||||||
|
**Phase 2 (naechster Hauptblock):**
|
||||||
|
- Serverseitige Kanalstruktur (Channel-Objekte im Backend)
|
||||||
|
- Device-Tracking bei jedem Login
|
||||||
|
- Admin-Panel im Browser: Benutzer verwalten, Geraete sehen
|
||||||
|
- Presence/Heartbeat (Online-Status)
|
||||||
|
- Hauptfenster `_send_to_empfang()` aufraumen (AO2/AO4/AO5/AO6)
|
||||||
|
|
||||||
|
**Phase 3 (mittelfristig):**
|
||||||
|
- Praxis-zu-Praxis-Federation
|
||||||
|
- WebSocket statt Polling
|
||||||
|
- Mobile-App (iPhone/Android)
|
||||||
|
- QR-Code-Kopplung
|
||||||
|
- 2FA fuer Admin
|
||||||
|
- Verschluesselte Nachrichtenspeicherung
|
||||||
|
|
||||||
|
**Phase 4 (spaeter):**
|
||||||
|
- Multi-Praxis produktiv (mehrere zahlende Praxen)
|
||||||
|
- Apple Watch / Wearable (nur Benachrichtigungen)
|
||||||
|
- Externe Praxis-Kanaele
|
||||||
|
- DSGVO-Loeschfunktionen
|
||||||
|
- Audit-Export
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"project": "AZA Medical AI Assistant",
|
"project": "AZA Medical AI Assistant",
|
||||||
"phase": "Device-/Seat-Logik V1 implementiert. Backup-Konzept + Deinstallations-UX als naechste Hauptbloecke.",
|
"phase": "Praxis-Chat V5 live (Auth + Admin + Devices + Channels + Federation). Admin-Panel eingebaut. Naechste Bloecke: Admin real pruefen, Empfangs-Huelle NaN-Bug, Uebersetzer-Bug.",
|
||||||
"current_step": "Device-Logik V1 fertig (2026-04-12). Naechste Bloecke: (1) Backup-Konzept vollstaendig, (2) Deinstallations-UX, (3) WooCommerce Verkaufspfad, (4) Browser-AZA.",
|
"current_step": "V5 deployed (2026-04-18). 40 Backend-Routen. Admin-Panel mit 5 Tabs (Praxis/Benutzer/Geraete/Kanaele/Verbindungen). Naechste Bloecke: (1) Admin-Panel real pruefen/fertigziehen, (2) Empfangs-Huelle NaN-Skalierungsbug fixen, (3) Uebersetzer-Zielsprache-Bug, (4) basis14.py _send_to_empfang Hauptblock.",
|
||||||
"last_completed": 19,
|
"last_completed": 21,
|
||||||
"next_step": "Naechsten Hauptblock waehlen (Admin-Token-Rotation / Betreiber-Runbook / WooCommerce / Lizenz-Lifecycle).",
|
"next_step": "Block 1: Browser-Admin-Panel als Admin real pruefen. Block 2: Empfangs-Huelle Aa/UI NaN-Bug fixen. Block 3: Uebersetzer-Bug aufnehmen.",
|
||||||
"last_update": "STATUS-PATCH 2026-04-12: (1) Device-/Seat-Logik V1 implementiert: 1 Lizenz = 2 Geraete, Stacking ueber Email, Backend fuehrend. (2) aza_device_enforcement.py komplett neu, backend_main.py + admin_routes.py + basis14.py erweitert. (3) Admin-Endpoint GET /admin/devices fuer Geraete-Uebersicht. (4) Naechste Bloecke: Backup-Konzept + Deinstallations-UX.",
|
"last_update": "STATUS-PATCH 2026-04-18b: V5 live. (1) Admin-Endpoints: Benutzer verwalten (deaktivieren/loeschen/Rolle/PW-Reset), Geraete (sperren/loeschen). (2) Device-Tracking bei jedem Login. (3) Channel-Grundlage (Allgemein/Aerzte/MPA/Empfang + custom + extern). (4) Federation-Grundlage (Einladung/Annahme/Trennung + automatische externe Kanaele). (5) Admin-Panel im Browser mit 5 Tabs. (6) Offene Bugs: Empfangs-Huelle Aa/UI NaN, Uebersetzer-Zielsprache. (7) Server-Deploy via scp (kein Git auf Hetzner). (8) Arbeitsmodus: nur live-tauglich, keine Provisorien, keine localStorage-Geschaeftsdaten.",
|
||||||
"updated_at": "2026-04-12",
|
"updated_at": "2026-04-18",
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"project_root": "C:\\Users\\surov\\Documents\\AZA_GIT\\aza",
|
"project_root": "C:\\Users\\surov\\Documents\\AZA_GIT\\aza",
|
||||||
"current_working_folder": "C:\\Users\\surov\\Documents\\AZA_GIT\\aza\\AzA march 2026",
|
"current_working_folder": "C:\\Users\\surov\\Documents\\AZA_GIT\\aza\\AzA march 2026",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"Do-not-break: Keine riskanten DNS-/Domain-Aenderungen an aza-medwork.ch ohne klare Pruefung. Website-Chaos durch vorschnelle Umschaltung war ein Fehler.",
|
"Do-not-break: Keine riskanten DNS-/Domain-Aenderungen an aza-medwork.ch ohne klare Pruefung. Website-Chaos durch vorschnelle Umschaltung war ein Fehler.",
|
||||||
"Do-not-break: OpenAI-Key NIEMALS hardcoded in App einbauen. NIEMALS Shared-Key an Kunden ausliefern. Lokale Key-Eingabe wird bei Produktivauslieferung entfernt (Variante B). Secrets NIEMALS loggen.",
|
"Do-not-break: OpenAI-Key NIEMALS hardcoded in App einbauen. NIEMALS Shared-Key an Kunden ausliefern. Lokale Key-Eingabe wird bei Produktivauslieferung entfernt (Variante B). Secrets NIEMALS loggen.",
|
||||||
"Do-not-break: Audioaufnahme IMMER als M4A (AAC via ffmpeg-Pipe). NIEMALS auf WAV zurueckaendern. WAV nur als Fallback wenn ffmpeg fehlt. Diese Entscheidung ist ENDGUELTIG – wurde bereits einmal faelschlich rueckgaengig gemacht.",
|
"Do-not-break: Audioaufnahme IMMER als M4A (AAC via ffmpeg-Pipe). NIEMALS auf WAV zurueckaendern. WAV nur als Fallback wenn ffmpeg fehlt. Diese Entscheidung ist ENDGUELTIG – wurde bereits einmal faelschlich rueckgaengig gemacht.",
|
||||||
|
"Do-not-break: AUTOTEXT FREEZE (April 2026). _run_global_autotext_listener(), _bind_autotext(), _periodic_focus_check(), _check_autotext_focus_out() sind eingefroren. NICHT aendern ausser bei reproduzierbarem Bug. Race-Condition-Fix (Deduplizierung + _autotext_injecting-Check in on_keyrelease) NICHT entfernen. Keine Disk-I/O in on_press/on_release. Keine Timing-Aenderungen. Autotext-Bereich in basis14.py (Zeilen ~8825-9082) explizit NICHT anfassen.",
|
||||||
"VERBINDLICH – V1-Lizenzmodell (2026-04-12): 1 aktive Lizenz = 2 gleichzeitig aktive Computer. Dieselbe Kaeufer-Email darf mehrere Lizenzen kaufen. Erlaubte Geraete addieren sich: 1 Lizenz = 2, 2 Lizenzen = 4, 3 Lizenzen = 6. Backend entscheidet fuehrend. Desktop sendet email + device_id. Backend liefert: license_active, allowed_devices, used_devices, device_allowed, reason. aza_device_enforcement.py ist fuehrende Implementierung.",
|
"VERBINDLICH – V1-Lizenzmodell (2026-04-12): 1 aktive Lizenz = 2 gleichzeitig aktive Computer. Dieselbe Kaeufer-Email darf mehrere Lizenzen kaufen. Erlaubte Geraete addieren sich: 1 Lizenz = 2, 2 Lizenzen = 4, 3 Lizenzen = 6. Backend entscheidet fuehrend. Desktop sendet email + device_id. Backend liefert: license_active, allowed_devices, used_devices, device_allowed, reason. aza_device_enforcement.py ist fuehrende Implementierung.",
|
||||||
"VERBINDLICH – Deinstallation: Nach Deinstallation KEIN sofortiger Zwangs-Neustart. Benutzer muss Option haben: jetzt neu starten / spaeter neu starten.",
|
"VERBINDLICH – Deinstallation: Nach Deinstallation KEIN sofortiger Zwangs-Neustart. Benutzer muss Option haben: jetzt neu starten / spaeter neu starten.",
|
||||||
"VERBINDLICH – Backup-Konzept HOHE PRIORITAET: Lokale App/Code/Builds, Installer-Artefakte, Hetzner-Backend/Server-Config/Daten, Stripe-Config/Referenzen, WordPress/WooCommerce/Website, Release-/Download-Dateien. Zusaetzlich Offsite-Sicherheitskopie in Luino.",
|
"VERBINDLICH – Backup-Konzept HOHE PRIORITAET: Lokale App/Code/Builds, Installer-Artefakte, Hetzner-Backend/Server-Config/Daten, Stripe-Config/Referenzen, WordPress/WooCommerce/Website, Release-/Download-Dateien. Zusaetzlich Offsite-Sicherheitskopie in Luino.",
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ def extract_terms_from_exchange(user_msg: str, ai_msg: str) -> list[str]:
|
|||||||
return terms
|
return terms
|
||||||
|
|
||||||
|
|
||||||
def load_text_font_size(key: str, default: int = 10) -> int:
|
def load_text_font_size(key: str, default: int = 9) -> int:
|
||||||
"""Lädt gespeicherte Schriftgröße aus translate_config.json."""
|
"""Lädt gespeicherte Schriftgröße aus translate_config.json."""
|
||||||
try:
|
try:
|
||||||
if os.path.exists(CONFIG_PATH):
|
if os.path.exists(CONFIG_PATH):
|
||||||
@@ -677,12 +677,12 @@ def main(parent=None):
|
|||||||
save_main_geometry(root.geometry())
|
save_main_geometry(root.geometry())
|
||||||
in_val = (lang_in_var.get() or "").strip()
|
in_val = (lang_in_var.get() or "").strip()
|
||||||
out_val = (lang_out_var.get() or "").strip()
|
out_val = (lang_out_var.get() or "").strip()
|
||||||
lin = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else "de"
|
lin = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else in_val
|
||||||
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else "en"
|
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else out_val
|
||||||
if lin not in ALL_GOOGLE_LANGUAGES:
|
if lin not in ALL_GOOGLE_LANGUAGES:
|
||||||
lin = "de"
|
lin = "de"
|
||||||
if lou not in ALL_GOOGLE_LANGUAGES:
|
if lou not in ALL_GOOGLE_LANGUAGES:
|
||||||
lou = "en"
|
lou = load_main_languages()[1]
|
||||||
save_main_languages(lin, lou)
|
save_main_languages(lin, lou)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -868,12 +868,12 @@ def main(parent=None):
|
|||||||
try:
|
try:
|
||||||
in_val = (lang_in_var.get() or "").strip()
|
in_val = (lang_in_var.get() or "").strip()
|
||||||
out_val = (lang_out_var.get() or "").strip()
|
out_val = (lang_out_var.get() or "").strip()
|
||||||
lin = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else "de"
|
lin = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else in_val
|
||||||
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else "en"
|
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else out_val
|
||||||
if lin not in ALL_GOOGLE_LANGUAGES:
|
if lin not in ALL_GOOGLE_LANGUAGES:
|
||||||
lin = "de"
|
lin = "de"
|
||||||
if lou not in ALL_GOOGLE_LANGUAGES:
|
if lou not in ALL_GOOGLE_LANGUAGES:
|
||||||
lou = "en"
|
lou = load_main_languages()[1]
|
||||||
save_main_languages(lin, lou)
|
save_main_languages(lin, lou)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -899,10 +899,14 @@ def main(parent=None):
|
|||||||
combo_out.bind("<<ComboboxSelected>>", _on_lang_selected)
|
combo_out.bind("<<ComboboxSelected>>", _on_lang_selected)
|
||||||
|
|
||||||
def get_lang_codes():
|
def get_lang_codes():
|
||||||
in_val = lang_in_var.get()
|
in_val = (lang_in_var.get() or "").strip()
|
||||||
out_val = lang_out_var.get()
|
out_val = (lang_out_var.get() or "").strip()
|
||||||
lang_in = in_val.split(" – ")[0] if " – " in in_val else "de"
|
lang_in = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else in_val
|
||||||
lang_out = out_val.split(" – ")[0] if " – " in out_val else "en"
|
lang_out = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else out_val
|
||||||
|
if lang_in not in ALL_GOOGLE_LANGUAGES:
|
||||||
|
lang_in = "de"
|
||||||
|
if lang_out not in ALL_GOOGLE_LANGUAGES:
|
||||||
|
lang_out = load_main_languages()[1]
|
||||||
return lang_in, lang_out
|
return lang_in, lang_out
|
||||||
|
|
||||||
def swap_languages():
|
def swap_languages():
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user