V5 komplett: Auth, Admin, Federation, Channels, Devices, Cockpit, Profil, Autotext-Fix, Uebersetzer-Fix

Made-with: Cursor
This commit is contained in:
2026-04-20 14:38:16 +02:00
parent c53bba4587
commit dcce7107ab
9 changed files with 2254 additions and 320 deletions

View File

@@ -231,7 +231,8 @@ function boot(){
f.onload=function(){
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(){
if(!done){diagnose()}
},12000);
@@ -258,7 +259,7 @@ async function doReload(){
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
};
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);
}

View File

@@ -69,10 +69,7 @@ def _draw_module_icon(c: tk.Canvas, key: str):
fill=fg, outline="")
elif key == "kg":
c.create_rectangle(10, 12, s - 10, s - 7, outline=fg, width=2)
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)
c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg)
elif key == "notizen":
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.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,
cursor="hand2")
aza_lbl.pack(anchor="w")
@@ -531,10 +528,15 @@ class AzaLauncher(tk.Tk):
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
top_row.pack(fill="x", pady=(0, 10))
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)
if mod_key == "kg" and self._logo_img:
icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor)
icon_lbl.image = self._logo_img
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"),
fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor)

View File

@@ -485,7 +485,7 @@ def save_paned_positions(positions):
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."""
try:
path = _font_sizes_config_path()

View File

@@ -503,7 +503,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin
self._autotext_data.setdefault("eventsSort", "soonest")
self._autotext_data.setdefault("eventsMonthsAhead", 13)
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("ui_font_delta", -1)
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.configure(bg="#E8F4FA")
dlg.resizable(True, True)
dlg.geometry("400x560")
dlg.minsize(380, 480)
dlg.geometry("520x820")
dlg.minsize(460, 680)
dlg.attributes("-topmost", True)
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)
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")
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(8, 0))
name_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
name_e.pack(fill="x", ipady=4, pady=(0, 6))
name_e.insert(0, self._user_profile.get("name", ""))
_FLD_FONT = ("Segoe UI", 9)
_LBL_FONT = ("Segoe UI", 9, "bold")
_PH_COLOR = "#aaa"
_FG_COLOR = "#1a4d6d"
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
spec_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
spec_e.pack(fill="x", ipady=4, pady=(0, 6))
spec_e.insert(0, self._user_profile.get("specialty", ""))
def _make_placeholder_entry(parent, placeholder, initial=""):
e = tk.Entry(parent, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat")
e.pack(fill="x", ipady=4, pady=(0, 6))
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
clinic_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
clinic_e.pack(fill="x", ipady=4, pady=(0, 6))
clinic_e.insert(0, self._user_profile.get("clinic", ""))
def _set_ph():
if not e.get().strip():
e.delete(0, "end")
e.insert(0, placeholder)
e.configure(fg=_PH_COLOR)
tk.Label(form, text="Code (ZSR/GLN, optional):", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
code_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
code_e.pack(fill="x", ipady=4, pady=(0, 6))
code_e.insert(0, self._user_profile.get("code", ""))
def _on_focus_in(ev):
if e.get() == placeholder and e.cget("fg") == _PH_COLOR:
e.delete(0, "end")
e.configure(fg=_FG_COLOR)
tk.Label(form, text="E-Mail (optional):", font=("Segoe UI", 10),
bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(4, 0))
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))
email_e.insert(0, self._user_profile.get("email", ""))
def _on_focus_out(ev):
_set_ph()
def _get_val():
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.pack(fill="x", pady=(8, 6))
tk.Label(form, text="🔑 Passwort ändern (optional):", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
tk.Label(form, text="\U0001f511 Passwort \u00e4ndern (optional):", font=_LBL_FONT,
bg="#E8F4FA", fg=_FG_COLOR).pack(anchor="w", pady=(2, 0))
tk.Label(form, text="Leer lassen, um das Passwort beizubehalten.",
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="")
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="")
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="")
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))
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:
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
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)
return
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
pw_hash = self._hash_password(new_pw)
else:
pw_hash = old_hash
_gv = lambda e: e._get_real_value() if hasattr(e, '_get_real_value') else e.get().strip()
updated = {
"name": name,
"specialty": spec_e.get().strip(),
"clinic": clinic_e.get().strip(),
"code": code_e.get().strip(),
"email": email_e.get().strip(),
"specialty": _gv(spec_e),
"clinic": _gv(clinic_e),
"code": _gv(code_e),
"email": _gv(email_e),
"password_hash": pw_hash,
}
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}")
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))
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",
relief="flat", padx=20, pady=6, cursor="hand2",
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)
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))
tfa_frame = tk.Frame(dlg, bg="#E8F4FA", padx=20)
tfa_frame = tk.Frame(scroll_inner, bg="#E8F4FA", padx=20)
tfa_frame.pack(fill="x")
is_active = self._user_profile.get("totp_active", False)
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.grab_set()
dlg.configure(bg="#F2F8FC")
dlg.geometry("520x620")
dlg.minsize(420, 500)
add_resize_grip(dlg, 420, 500)
center_window(dlg, 520, 620)
body = tk.Frame(dlg, bg="#F2F8FC", padx=12, pady=12)
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")
primary_var = tk.StringVar(value="dermatology")
primary_var = tk.StringVar(value="")
label_to_key = {label: key 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 = ttk.Combobox(body, values=[label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
combo_val = tk.StringVar(value="")
combo = ttk.Combobox(body, values=[""] + [label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
combo.pack(anchor="w", pady=(6, 10))
tk.Label(body, text="Weitere Fachgebiete (optional)", bg="#F2F8FC", fg="#1a4d6d").pack(anchor="w")
vars_map = {}
for key, label in catalog:
v = tk.BooleanVar(value=(key == "dermatology"))
v = tk.BooleanVar(value=False)
vars_map[key] = v
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)
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 = []
def _save_prefs():
@@ -7386,11 +7513,12 @@ WICHTIG unbedingt einhalten:
dlg.title("KI-Einwilligung erforderlich")
dlg.transient(self)
dlg.grab_set()
dlg.geometry("680x520")
dlg.minsize(500, 400)
dlg.geometry("920x720")
dlg.minsize(700, 550)
dlg.attributes("-topmost", True)
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.pack(fill="both", expand=True)
@@ -8769,7 +8897,11 @@ WICHTIG:
text_widget.bind("<FocusIn>", on_focus_in, add="+")
text_widget.bind("<FocusOut>", on_focus_out, add="+")
_last_expansion = [0.0, ""]
def on_keyrelease(event):
if getattr(self, "_autotext_injecting", [False])[0]:
return
if not getattr(self, "_autotext_data", {}).get("enabled", True):
return
entries = (self._autotext_data.get("entries") or {})
@@ -8792,7 +8924,12 @@ WICHTIG:
word = text_before[word_start:word_end]
if not word or word not in entries:
return
now = time.time()
if _last_expansion[1] == word and now - _last_expansion[0] < 1.0:
return
expansion = entries[word]
_last_expansion[0] = now
_last_expansion[1] = word
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
text_widget.delete(start_idx, insert)
text_widget.insert(start_idx, expansion + last_char)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# AZA Master Handover / Operational Runbook
## Arbeitsmodus / Regeln
## Arbeitsmodus / Regeln (VERBINDLICH)
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.
- Jede Aenderung in 1 Patch, kein schrittweises Anleiten.
- 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)
@@ -218,40 +284,190 @@ kurze Antworten, kein voller Hauptclient).
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):**
- 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
Interne Kurzbezeichnung: **AZA Praxis-Federation**
**Was noch fehlt fuer V2:**
- practice_id in allen Entitaeten
- Echte Authentifizierung (JWT/Session)
- Serverseitige Kanalstruktur
- Serverseitige Aufgaben (statt localStorage)
- Geraeteverwaltung fuer Praxis-Admin
- QR-Code-Kopplung fuer Mobile
**Grundprinzip:** Praxen sind standardmaessig vollstaendig getrennt.
Eine Verbindung entsteht NUR durch explizite beidseitige Zustimmung.
**Ablauf:**
1. Admin Praxis A erzeugt Verbindungseinladung (Einmal-Code, 48h gueltig)
2. Admin Praxis B gibt Code ein und bestaetigt
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
#### 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):**
Frontend-Layout, Benutzer-Sync, Chat-Threads. Kein Backend-Umbau.
Einzelpraxis-Betrieb reicht. practice_id wird als Konzept vorbereitet,
aber noch nicht erzwungen.
**Externer Praxis-Kontakt sieht:**
- NUR den freigegebenen externen Kanal
- Keine internen Daten, Benutzer, Aufgaben der anderen Praxis
**Phase 2 (mittelfristig):**
Backend: practice_id + user_id + JWT-Auth einfuehren. Kanalstruktur serverseitig.
Aufgaben serverseitig. Geraeteverwaltung. Admin-Panel fuer Practice Admin.
Presence/Heartbeat. Invite-Links.
#### 6.15 Aktueller Stand (nach V4-Deploy, 2026-04-18)
**Phase 3 (spaeter):**
Multi-Tenant produktiv (mehrere Praxen). WebSocket statt Polling. Mobile-App.
QR-Code-Kopplung. 2FA. Verschluesselte Speicherung. Externe Praxis-Verbindungen.
**Was jetzt implementiert ist:**
- Serverseitige Auth: PBKDF2 + Session-Cookie (empfang_routes.py V4)
- 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
---

View File

@@ -1,11 +1,11 @@
{
"project": "AZA Medical AI Assistant",
"phase": "Device-/Seat-Logik V1 implementiert. Backup-Konzept + Deinstallations-UX als naechste Hauptbloecke.",
"current_step": "Device-Logik V1 fertig (2026-04-12). Naechste Bloecke: (1) Backup-Konzept vollstaendig, (2) Deinstallations-UX, (3) WooCommerce Verkaufspfad, (4) Browser-AZA.",
"last_completed": 19,
"next_step": "Naechsten Hauptblock waehlen (Admin-Token-Rotation / Betreiber-Runbook / WooCommerce / Lizenz-Lifecycle).",
"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.",
"updated_at": "2026-04-12",
"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": "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": 21,
"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-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-18",
"workspace": {
"project_root": "C:\\Users\\surov\\Documents\\AZA_GIT\\aza",
"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: 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: 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 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.",

View File

@@ -420,7 +420,7 @@ def extract_terms_from_exchange(user_msg: str, ai_msg: str) -> list[str]:
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."""
try:
if os.path.exists(CONFIG_PATH):
@@ -677,12 +677,12 @@ def main(parent=None):
save_main_geometry(root.geometry())
in_val = (lang_in_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"
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else "en"
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 out_val
if lin not in ALL_GOOGLE_LANGUAGES:
lin = "de"
if lou not in ALL_GOOGLE_LANGUAGES:
lou = "en"
lou = load_main_languages()[1]
save_main_languages(lin, lou)
except Exception:
pass
@@ -868,12 +868,12 @@ def main(parent=None):
try:
in_val = (lang_in_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"
lou = out_val.split(" \u2013 ")[0].strip() if " \u2013 " in out_val else "en"
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 out_val
if lin not in ALL_GOOGLE_LANGUAGES:
lin = "de"
if lou not in ALL_GOOGLE_LANGUAGES:
lou = "en"
lou = load_main_languages()[1]
save_main_languages(lin, lou)
except Exception:
pass
@@ -899,10 +899,14 @@ def main(parent=None):
combo_out.bind("<<ComboboxSelected>>", _on_lang_selected)
def get_lang_codes():
in_val = lang_in_var.get()
out_val = lang_out_var.get()
lang_in = in_val.split(" ")[0] if " " in in_val else "de"
lang_out = out_val.split(" ")[0] if " " in out_val else "en"
in_val = (lang_in_var.get() or "").strip()
out_val = (lang_out_var.get() or "").strip()
lang_in = in_val.split(" \u2013 ")[0].strip() if " \u2013 " in in_val else in_val
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
def swap_languages():

File diff suppressed because it is too large Load Diff