From dcce7107aba5f82f1887fc25a980bfd65ad349d8 Mon Sep 17 00:00:00 2001 From: suro Date: Mon, 20 Apr 2026 14:38:16 +0200 Subject: [PATCH] V5 komplett: Auth, Admin, Federation, Channels, Devices, Cockpit, Profil, Autotext-Fix, Uebersetzer-Fix Made-with: Cursor --- AzA march 2026/aza_empfang_app.py | 5 +- AzA march 2026/aza_launcher.py | 20 +- AzA march 2026/aza_ui_helpers.py | 2 +- AzA march 2026/basis14.py | 245 +++++-- AzA march 2026/empfang_routes.py | 903 ++++++++++++++++++++++- AzA march 2026/handover.md | 276 ++++++- AzA march 2026/project_status.json | 13 +- AzA march 2026/translate.py | 26 +- AzA march 2026/web/empfang.html | 1084 +++++++++++++++++++++++----- 9 files changed, 2254 insertions(+), 320 deletions(-) diff --git a/AzA march 2026/aza_empfang_app.py b/AzA march 2026/aza_empfang_app.py index 449bb65..3cc9d3d 100644 --- a/AzA march 2026/aza_empfang_app.py +++ b/AzA march 2026/aza_empfang_app.py @@ -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); } diff --git a/AzA march 2026/aza_launcher.py b/AzA march 2026/aza_launcher.py index c09ff90..4d25348 100644 --- a/AzA march 2026/aza_launcher.py +++ b/AzA march 2026/aza_launcher.py @@ -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) diff --git a/AzA march 2026/aza_ui_helpers.py b/AzA march 2026/aza_ui_helpers.py index f2164c5..4c44310 100644 --- a/AzA march 2026/aza_ui_helpers.py +++ b/AzA march 2026/aza_ui_helpers.py @@ -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() diff --git a/AzA march 2026/basis14.py b/AzA march 2026/basis14.py index bf15402..aa0ad81 100644 --- a/AzA march 2026/basis14.py +++ b/AzA march 2026/basis14.py @@ -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("", lambda e: scroll_canvas.configure(scrollregion=scroll_canvas.bbox("all"))) + scroll_canvas.bind("", lambda e: scroll_canvas.itemconfigure(scroll_win, width=e.width)) + scroll_canvas.bind_all("", lambda e: scroll_canvas.yview_scroll(-1 * (e.delta // 120), "units")) + dlg.bind("", lambda e: scroll_canvas.unbind_all("") 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("", _on_focus_in) + e.bind("", _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("", 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("", on_focus_in, add="+") text_widget.bind("", 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) diff --git a/AzA march 2026/empfang_routes.py b/AzA march 2026/empfang_routes.py index f2ebd40..ba4b0c4 100644 --- a/AzA march 2026/empfang_routes.py +++ b/AzA march 2026/empfang_routes.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """ -AZA Empfang - Backend-Routen V4. -Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben. +AZA Empfang - Backend-Routen V5: Admin, Devices, Federation, Channels. +Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben, +Geraeteverwaltung, Kanaele, Praxis-Federation. Alle Daten practice-scoped. Backend ist die einzige Wahrheit. """ @@ -9,6 +10,7 @@ import hashlib import hmac import json import os +import re import secrets import time import uuid @@ -27,6 +29,9 @@ _PRACTICES_FILE = _DATA_DIR / "empfang_practices.json" _ACCOUNTS_FILE = _DATA_DIR / "empfang_accounts.json" _SESSIONS_FILE = _DATA_DIR / "empfang_sessions.json" _TASKS_FILE = _DATA_DIR / "empfang_tasks.json" +_DEVICES_FILE = _DATA_DIR / "empfang_devices.json" +_CHANNELS_FILE = _DATA_DIR / "empfang_channels.json" +_CONNECTIONS_FILE = _DATA_DIR / "empfang_connections.json" DEFAULT_PRACTICE_ID = "default" SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage @@ -128,6 +133,9 @@ def _migrate_old_users(practice_id: str): "pw_hash": pw_hash, "pw_salt": pw_salt, "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "status": "active", + "last_login": "", + "email": "", } _save_accounts(accounts) except Exception: @@ -156,7 +164,7 @@ def _practice_users(practice_id: str) -> list[dict]: # ===================================================================== -# Sessions +# Sessions (mit device_id) # ===================================================================== def _load_sessions() -> dict: @@ -167,7 +175,9 @@ def _save_sessions(data: dict): _save_json(_SESSIONS_FILE, data) -def _create_session(user_id: str, practice_id: str, display_name: str, role: str) -> str: +def _create_session(user_id: str, practice_id: str, display_name: str, + role: str, device_id: str = None, + user_agent: str = "", ip_addr: str = "") -> str: token = secrets.token_urlsafe(32) sessions = _load_sessions() sessions[token] = { @@ -175,10 +185,21 @@ def _create_session(user_id: str, practice_id: str, display_name: str, role: str "practice_id": practice_id, "display_name": display_name, "role": role, + "device_id": device_id or "", "created": time.time(), "last_active": time.time(), } _save_sessions(sessions) + + if device_id: + _register_or_update_device( + device_id=device_id, + user_id=user_id, + practice_id=practice_id, + user_agent=user_agent, + ip_addr=ip_addr, + ) + return token @@ -224,6 +245,113 @@ def _require_session(request: Request) -> dict: return s +# ===================================================================== +# Devices (Geraeteverwaltung) +# ===================================================================== + +def _load_devices() -> dict: + return _load_json(_DEVICES_FILE, {}) + + +def _save_devices(data: dict): + _save_json(_DEVICES_FILE, data) + + +def _parse_device_info(user_agent: str) -> dict: + """Einfache Heuristik zum Erkennen von Plattform, Geraetetyp und Name.""" + ua = user_agent.lower() + + if "iphone" in ua: + platform, device_type = "iOS", "mobile" + elif "ipad" in ua: + platform, device_type = "iOS", "tablet" + elif "android" in ua: + if "mobile" in ua: + platform, device_type = "Android", "mobile" + else: + platform, device_type = "Android", "tablet" + elif "macintosh" in ua or "mac os" in ua: + platform, device_type = "macOS", "browser" + elif "windows" in ua: + platform, device_type = "Windows", "browser" + elif "linux" in ua: + platform, device_type = "Linux", "browser" + else: + platform, device_type = "Unbekannt", "browser" + + if "electron" in ua or "cursor" in ua: + device_type = "desktop" + + browser = "Browser" + if "edg/" in ua: + browser = "Edge" + elif "chrome" in ua and "chromium" not in ua: + browser = "Chrome" + elif "firefox" in ua: + browser = "Firefox" + elif "safari" in ua and "chrome" not in ua: + browser = "Safari" + + device_name = f"{browser} auf {platform}" + if device_type == "desktop": + device_name = f"Desktop-App auf {platform}" + elif device_type in ("mobile", "tablet"): + device_name = f"{platform} {device_type.capitalize()}" + + return { + "device_name": device_name, + "platform": platform, + "device_type": device_type, + } + + +def _make_device_id(user_id: str, user_agent: str) -> str: + raw = f"{user_id}:{user_agent}" + return hashlib.sha256(raw.encode()).hexdigest()[:12] + + +def _register_or_update_device(device_id: str, user_id: str, + practice_id: str, user_agent: str, + ip_addr: str): + devices = _load_devices() + now = time.strftime("%Y-%m-%d %H:%M:%S") + info = _parse_device_info(user_agent) + + if device_id in devices: + dev = devices[device_id] + dev["last_active"] = now + dev["ip_last"] = ip_addr + dev["user_agent"] = user_agent + dev["device_name"] = info["device_name"] + dev["platform"] = info["platform"] + dev["device_type"] = info["device_type"] + else: + devices[device_id] = { + "device_id": device_id, + "user_id": user_id, + "practice_id": practice_id, + "device_name": info["device_name"], + "platform": info["platform"], + "device_type": info["device_type"], + "user_agent": user_agent, + "first_seen": now, + "last_active": now, + "trust_status": "trusted", + "ip_last": ip_addr, + } + + _save_devices(devices) + + +def _extract_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "" + + # ===================================================================== # Messages (practice-scoped) # ===================================================================== @@ -256,6 +384,59 @@ def _save_tasks(tasks: list[dict]): _save_json(_TASKS_FILE, tasks) +# ===================================================================== +# Channels (Kanaele, practice-scoped) +# ===================================================================== + +def _load_channels() -> list[dict]: + return _load_json(_CHANNELS_FILE, []) + + +def _save_channels(channels: list[dict]): + _save_json(_CHANNELS_FILE, channels) + + +_DEFAULT_CHANNEL_DEFS = [ + {"name": "Allgemein", "scope": "internal", "channel_type": "group", "allowed_roles": []}, + {"name": "Aerzte", "scope": "internal", "channel_type": "group", "allowed_roles": ["arzt", "admin"]}, + {"name": "MPA", "scope": "internal", "channel_type": "group", "allowed_roles": ["mpa", "admin"]}, + {"name": "Empfang", "scope": "internal", "channel_type": "group", "allowed_roles": ["empfang", "admin"]}, +] + + +def _ensure_default_channels(practice_id: str): + channels = _load_channels() + practice_channels = [c for c in channels if c.get("practice_id") == practice_id] + if practice_channels: + return + now = time.strftime("%Y-%m-%d %H:%M:%S") + for defn in _DEFAULT_CHANNEL_DEFS: + channels.append({ + "channel_id": uuid.uuid4().hex[:12], + "practice_id": practice_id, + "name": defn["name"], + "scope": defn["scope"], + "channel_type": defn["channel_type"], + "allowed_roles": defn["allowed_roles"], + "connection_id": "", + "created": now, + "created_by": "", + }) + _save_channels(channels) + + +# ===================================================================== +# Connections / Federation (Praxis-zu-Praxis) +# ===================================================================== + +def _load_connections() -> list[dict]: + return _load_json(_CONNECTIONS_FILE, []) + + +def _save_connections(conns: list[dict]): + _save_json(_CONNECTIONS_FILE, conns) + + # ===================================================================== # AUTH ENDPOINTS # ===================================================================== @@ -277,25 +458,46 @@ async def auth_setup(request: Request): body = {} name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() + practice_name = (body.get("practice_name") or "").strip() + admin_email = (body.get("email") or "").strip() if not name or not password or len(password) < 4: raise HTTPException(status_code=400, detail="Name und Passwort (min. 4 Zeichen) erforderlich") + if practice_name: + practices = _load_practices() + practices[DEFAULT_PRACTICE_ID]["name"] = practice_name + if admin_email: + practices[DEFAULT_PRACTICE_ID]["admin_email"] = admin_email + _save_practices(practices) + practice = practices[DEFAULT_PRACTICE_ID] uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(password) + now = time.strftime("%Y-%m-%d %H:%M:%S") accounts[uid] = { "user_id": uid, "practice_id": DEFAULT_PRACTICE_ID, "display_name": name, + "email": admin_email, "role": "admin", "pw_hash": pw_hash, "pw_salt": pw_salt, - "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "created": now, + "status": "active", + "last_login": now, } _save_accounts(accounts) - token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin") + + _ensure_default_channels(DEFAULT_PRACTICE_ID) + + ua = request.headers.get("User-Agent", "") + ip = _extract_client_ip(request) + dev_id = _make_device_id(uid, ua) + token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin", + device_id=dev_id, user_agent=ua, ip_addr=ip) resp = JSONResponse(content={ "success": True, "user_id": uid, "role": "admin", "display_name": name, "practice_id": DEFAULT_PRACTICE_ID, + "practice_name": practice.get("name", ""), "invite_code": practice.get("invite_code", ""), }) resp.set_cookie("aza_session", token, httponly=True, samesite="lax", @@ -305,7 +507,7 @@ async def auth_setup(request: Request): @router.post("/auth/login") async def auth_login(request: Request): - """Login mit Name + Passwort.""" + """Login mit Name oder E-Mail + Passwort.""" try: body = await request.json() except Exception: @@ -314,22 +516,45 @@ async def auth_login(request: Request): password = (body.get("password") or "").strip() pid = (body.get("practice_id") or "").strip() or DEFAULT_PRACTICE_ID if not name or not password: - raise HTTPException(status_code=400, detail="Name und Passwort erforderlich") + raise HTTPException(status_code=400, detail="Name/E-Mail und Passwort erforderlich") accounts = _load_accounts() + name_lower = name.lower() target = None for a in accounts.values(): - if a["display_name"] == name and a.get("practice_id") == pid: + if a.get("practice_id") != pid: + continue + if a["display_name"] == name: + target = a + break + if (a.get("email") or "").strip().lower() == name_lower and name_lower: target = a break if not target: raise HTTPException(status_code=401, detail="Benutzer nicht gefunden") + if target.get("status") == "deactivated": + raise HTTPException(status_code=403, + detail="Konto deaktiviert. Bitte Administrator kontaktieren.") if not _verify_password(password, target["pw_hash"], target["pw_salt"]): raise HTTPException(status_code=401, detail="Falsches Passwort") - token = _create_session(target["user_id"], pid, name, target["role"]) - resp = JSONResponse(content={ + + now = time.strftime("%Y-%m-%d %H:%M:%S") + target["last_login"] = now + _save_accounts(accounts) + + ua = request.headers.get("User-Agent", "") + ip = _extract_client_ip(request) + dev_id = body.get("device_id") or _make_device_id(target["user_id"], ua) + token = _create_session(target["user_id"], pid, name, target["role"], + device_id=dev_id, user_agent=ua, ip_addr=ip) + + result = { "success": True, "user_id": target["user_id"], "role": target["role"], "display_name": name, "practice_id": pid, - }) + } + if target.get("must_change_password"): + result["must_change_password"] = True + + resp = JSONResponse(content=result) resp.set_cookie("aza_session", token, httponly=True, samesite="lax", max_age=SESSION_MAX_AGE) return resp @@ -346,10 +571,11 @@ async def auth_register(request: Request): name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() role = (body.get("role") or "mpa").strip() + email = (body.get("email") or "").strip() if not invite_code or not name or not password or len(password) < 4: raise HTTPException(status_code=400, detail="Einladungscode, Name und Passwort (min. 4 Zeichen) erforderlich") - if role not in ("admin", "arzt", "mpa", "empfang"): + if role not in ("arzt", "mpa", "empfang"): role = "mpa" practices = _load_practices() target_pid = None @@ -366,17 +592,28 @@ async def auth_register(request: Request): raise HTTPException(status_code=409, detail="Benutzername bereits vergeben") uid = uuid.uuid4().hex[:12] pw_hash, pw_salt = _hash_password(password) + now = time.strftime("%Y-%m-%d %H:%M:%S") accounts[uid] = { "user_id": uid, "practice_id": target_pid, "display_name": name, + "email": email, "role": role, "pw_hash": pw_hash, "pw_salt": pw_salt, - "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "created": now, + "status": "active", + "last_login": now, } _save_accounts(accounts) - token = _create_session(uid, target_pid, name, role) + + _ensure_default_channels(target_pid) + + ua = request.headers.get("User-Agent", "") + ip = _extract_client_ip(request) + dev_id = _make_device_id(uid, ua) + token = _create_session(uid, target_pid, name, role, + device_id=dev_id, user_agent=ua, ip_addr=ip) resp = JSONResponse(content={ "success": True, "user_id": uid, "role": role, "display_name": name, "practice_id": target_pid, @@ -410,6 +647,156 @@ async def auth_logout(request: Request): return resp +@router.post("/auth/regenerate_invite") +async def auth_regenerate_invite(request: Request): + """Erzeugt einen neuen Einladungscode (nur Admin).""" + s = _require_session(request) + if s.get("role") != "admin": + raise HTTPException(status_code=403, detail="Nur Admin darf Einladungscode erneuern") + practices = _load_practices() + pid = s["practice_id"] + if pid in practices: + practices[pid]["invite_code"] = secrets.token_urlsafe(8) + _save_practices(practices) + return JSONResponse(content={ + "success": True, + "invite_code": practices.get(pid, {}).get("invite_code", ""), + }) + + +@router.post("/auth/forgot_password") +async def auth_forgot_password(request: Request): + """Sendet einen Passwort-Reset-Link per E-Mail.""" + try: + body = await request.json() + except Exception: + body = {} + email = (body.get("email") or "").strip().lower() + if not email: + raise HTTPException(status_code=400, detail="E-Mail-Adresse erforderlich") + accounts = _load_accounts() + target = None + for a in accounts.values(): + if (a.get("email") or "").strip().lower() == email: + target = a + break + if not target: + return JSONResponse(content={"success": True, + "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}) + reset_token = secrets.token_urlsafe(32) + resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) + resets[reset_token] = { + "user_id": target["user_id"], + "email": email, + "created": time.time(), + } + for k in list(resets.keys()): + if time.time() - resets[k].get("created", 0) > 3600: + del resets[k] + _save_json(_DATA_DIR / "empfang_resets.json", resets) + reset_link = f"https://empfang.aza-medwork.ch/?reset_token={reset_token}" + _send_reset_email(email, target["display_name"], reset_link) + return JSONResponse(content={"success": True, + "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}) + + +@router.post("/auth/reset_password") +async def auth_reset_password(request: Request): + """Setzt das Passwort mit einem gueltigen Reset-Token.""" + try: + body = await request.json() + except Exception: + body = {} + token = (body.get("reset_token") or "").strip() + new_password = (body.get("password") or "").strip() + if not token or not new_password or len(new_password) < 4: + raise HTTPException(status_code=400, + detail="Reset-Token und neues Passwort (min. 4 Zeichen) erforderlich") + resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) + entry = resets.get(token) + if not entry: + raise HTTPException(status_code=400, detail="Ungueltiger oder abgelaufener Reset-Link") + if time.time() - entry.get("created", 0) > 3600: + del resets[token] + _save_json(_DATA_DIR / "empfang_resets.json", resets) + raise HTTPException(status_code=400, detail="Reset-Link ist abgelaufen (max. 1 Stunde)") + user_id = entry["user_id"] + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + pw_hash, pw_salt = _hash_password(new_password) + accounts[user_id]["pw_hash"] = pw_hash + accounts[user_id]["pw_salt"] = pw_salt + accounts[user_id].pop("must_change_password", None) + _save_accounts(accounts) + del resets[token] + _save_json(_DATA_DIR / "empfang_resets.json", resets) + return JSONResponse(content={"success": True, "message": "Passwort wurde erfolgreich geaendert."}) + + +def _send_reset_email(to_email: str, display_name: str, reset_link: str): + """Sendet Passwort-Reset-E-Mail via SMTP.""" + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + host = os.environ.get("SMTP_HOST", "").strip() + port_str = os.environ.get("SMTP_PORT", "587").strip() + user = os.environ.get("SMTP_USER", "").strip() + password = os.environ.get("SMTP_PASS", "").strip() + sender = os.environ.get("SMTP_FROM", "").strip() or user + + if not all([host, user, password]): + print(f"[RESET-MAIL] SMTP nicht konfiguriert – Reset-Link: {reset_link}") + return + + subject = "AZA Praxis-Chat – Passwort zuruecksetzen" + text = ( + f"Hallo {display_name},\n\n" + f"Sie haben eine Passwort-Zuruecksetzung angefordert.\n\n" + f"Klicken Sie auf diesen Link, um Ihr Passwort neu zu setzen:\n" + f"{reset_link}\n\n" + f"Der Link ist 1 Stunde gueltig.\n\n" + f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n" + f"AZA Praxis-Chat" + ) + html = ( + f"
" + f"

Passwort zuruecksetzen

" + f"

Hallo {display_name},

" + f"

Sie haben eine Passwort-Zuruecksetzung angefordert.

" + f"

Neues Passwort setzen

" + f"

Der Link ist 1 Stunde gueltig.

" + f"

Falls Sie diese Anfrage nicht gestellt haben, " + f"ignorieren Sie diese E-Mail.

" + ) + try: + msg = MIMEMultipart("alternative") + msg["From"] = sender + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(text, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + port = int(port_str) + if port == 465: + with smtplib.SMTP_SSL(host, port, timeout=15) as srv: + srv.login(user, password) + srv.sendmail(sender, [to_email], msg.as_string()) + else: + with smtplib.SMTP(host, port, timeout=15) as srv: + srv.ehlo() + srv.starttls() + srv.ehlo() + srv.login(user, password) + srv.sendmail(sender, [to_email], msg.as_string()) + print(f"[RESET-MAIL] OK -> {to_email}") + except Exception as exc: + print(f"[RESET-MAIL] FEHLER: {exc}") + print(f"[RESET-MAIL] Reset-Link: {reset_link}") + + @router.get("/auth/needs_setup") async def auth_needs_setup(): """Pruefen ob Setup noetig ist (keine Accounts vorhanden).""" @@ -425,6 +812,216 @@ async def auth_needs_setup(): }) +# ===================================================================== +# ADMIN ENDPOINTS (nur Rolle admin) +# ===================================================================== + +def _require_admin(request: Request) -> dict: + s = _require_session(request) + if s.get("role") != "admin": + raise HTTPException(status_code=403, detail="Admin-Berechtigung erforderlich") + return s + + +@router.get("/admin/users") +async def admin_list_users(request: Request): + """Alle Benutzer der Praxis mit vollen Details.""" + s = _require_admin(request) + pid = s["practice_id"] + accounts = _load_accounts() + result = [] + for a in accounts.values(): + if a.get("practice_id") != pid: + continue + result.append({ + "user_id": a["user_id"], + "display_name": a["display_name"], + "role": a.get("role", "mpa"), + "status": a.get("status", "active"), + "created": a.get("created", ""), + "last_login": a.get("last_login", ""), + "email": a.get("email", ""), + }) + return JSONResponse(content={"success": True, "users": result}) + + +@router.post("/admin/users/{user_id}/role") +async def admin_change_role(user_id: str, request: Request): + """Rolle eines Benutzers aendern.""" + s = _require_admin(request) + try: + body = await request.json() + except Exception: + body = {} + new_role = (body.get("role") or "").strip() + if not new_role: + raise HTTPException(status_code=400, detail="Rolle erforderlich") + if new_role not in ("admin", "arzt", "mpa", "empfang"): + raise HTTPException(status_code=400, detail="Ungueltige Rolle") + if user_id == s["user_id"] and new_role != "admin": + raise HTTPException(status_code=400, + detail="Eigene Admin-Rolle kann nicht entfernt werden") + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + accounts[user_id]["role"] = new_role + _save_accounts(accounts) + return JSONResponse(content={"success": True, "user_id": user_id, "role": new_role}) + + +@router.post("/admin/users/{user_id}/deactivate") +async def admin_deactivate_user(user_id: str, request: Request): + """Benutzer deaktivieren und alle Sessions loeschen.""" + s = _require_admin(request) + if user_id == s["user_id"]: + raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht deaktiviert werden") + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + accounts[user_id]["status"] = "deactivated" + _save_accounts(accounts) + + sessions = _load_sessions() + sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} + _save_sessions(sessions) + + return JSONResponse(content={"success": True, "user_id": user_id, "status": "deactivated"}) + + +@router.post("/admin/users/{user_id}/activate") +async def admin_activate_user(user_id: str, request: Request): + """Benutzer reaktivieren.""" + s = _require_admin(request) + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + accounts[user_id]["status"] = "active" + _save_accounts(accounts) + return JSONResponse(content={"success": True, "user_id": user_id, "status": "active"}) + + +@router.delete("/admin/users/{user_id}") +async def admin_delete_user(user_id: str, request: Request): + """Benutzer permanent loeschen inkl. Sessions und Geraete.""" + s = _require_admin(request) + if user_id == s["user_id"]: + raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht geloescht werden") + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + del accounts[user_id] + _save_accounts(accounts) + + sessions = _load_sessions() + sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} + _save_sessions(sessions) + + devices = _load_devices() + devices = {k: v for k, v in devices.items() if v.get("user_id") != user_id} + _save_devices(devices) + + return JSONResponse(content={"success": True, "deleted": user_id}) + + +@router.post("/admin/users/{user_id}/reset_password") +async def admin_reset_password(user_id: str, request: Request): + """Temporaeres Passwort generieren. Benutzer muss es beim naechsten Login aendern.""" + s = _require_admin(request) + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + temp_pw = secrets.token_urlsafe(8) + pw_hash, pw_salt = _hash_password(temp_pw) + accounts[user_id]["pw_hash"] = pw_hash + accounts[user_id]["pw_salt"] = pw_salt + accounts[user_id]["must_change_password"] = True + _save_accounts(accounts) + return JSONResponse(content={ + "success": True, "user_id": user_id, + "temp_password": temp_pw, + }) + + +@router.get("/admin/devices") +async def admin_list_devices(request: Request): + """Alle Geraete aller Benutzer der Praxis.""" + s = _require_admin(request) + pid = s["practice_id"] + devices = _load_devices() + accounts = _load_accounts() + user_names = {a["user_id"]: a["display_name"] for a in accounts.values()} + result = [] + for d in devices.values(): + if d.get("practice_id") != pid: + continue + entry = dict(d) + entry["user_name"] = user_names.get(d.get("user_id"), d.get("user_id", "")) + result.append(entry) + result.sort(key=lambda d: d.get("last_active", ""), reverse=True) + return JSONResponse(content={"success": True, "devices": result}) + + +@router.post("/admin/devices/{device_id}/block") +async def admin_block_device(device_id: str, request: Request): + """Geraet blockieren und zugehoerige Sessions loeschen.""" + s = _require_admin(request) + devices = _load_devices() + if device_id not in devices: + raise HTTPException(status_code=404, detail="Geraet nicht gefunden") + dev = devices[device_id] + if dev.get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis") + dev["trust_status"] = "blocked" + _save_devices(devices) + + sessions = _load_sessions() + sessions = {k: v for k, v in sessions.items() + if v.get("device_id") != device_id} + _save_sessions(sessions) + + return JSONResponse(content={"success": True, "device_id": device_id, "trust_status": "blocked"}) + + +@router.delete("/admin/devices/{device_id}") +async def admin_delete_device(device_id: str, request: Request): + """Geraetedatensatz loeschen.""" + s = _require_admin(request) + devices = _load_devices() + if device_id not in devices: + raise HTTPException(status_code=404, detail="Geraet nicht gefunden") + if devices[device_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis") + del devices[device_id] + _save_devices(devices) + return JSONResponse(content={"success": True, "deleted": device_id}) + + +@router.post("/admin/users/{user_id}/logout_all") +async def admin_logout_all(user_id: str, request: Request): + """Alle Sessions eines Benutzers loeschen.""" + s = _require_admin(request) + accounts = _load_accounts() + if user_id not in accounts: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") + if accounts[user_id].get("practice_id") != s["practice_id"]: + raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis") + sessions = _load_sessions() + removed = sum(1 for v in sessions.values() if v.get("user_id") == user_id) + sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id} + _save_sessions(sessions) + return JSONResponse(content={"success": True, "user_id": user_id, "sessions_removed": removed}) + + # ===================================================================== # USER MANAGEMENT (admin only for invite/role changes) # ===================================================================== @@ -501,6 +1098,9 @@ async def empfang_register_user(request: Request): "pw_hash": pw_hash, "pw_salt": pw_salt, "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "status": "active", + "last_login": "", + "email": "", } _save_accounts(accounts) users = _practice_users(pid) @@ -696,6 +1296,278 @@ async def empfang_tasks_delete(task_id: str): return JSONResponse(content={"success": True}) +# ===================================================================== +# CHANNEL ENDPOINTS (Kanaele) +# ===================================================================== + +@router.get("/channels") +async def channels_list(request: Request): + """Kanaele anzeigen, gefiltert nach Rolle des Benutzers.""" + s = _require_session(request) + pid = s["practice_id"] + role = s.get("role", "mpa") + _ensure_default_channels(pid) + channels = _load_channels() + visible = [] + for c in channels: + if c.get("practice_id") != pid: + continue + allowed = c.get("allowed_roles", []) + if not allowed or role in allowed: + visible.append(c) + return JSONResponse(content={"success": True, "channels": visible}) + + +@router.post("/channels") +async def channels_create(request: Request): + """Neuen Kanal erstellen (nur Admin).""" + s = _require_admin(request) + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Kanalname erforderlich") + scope = body.get("scope", "internal") + if scope not in ("internal", "external"): + scope = "internal" + channel_type = body.get("channel_type", "group") + if channel_type not in ("group", "direct", "external"): + channel_type = "group" + allowed_roles = body.get("allowed_roles", []) + if not isinstance(allowed_roles, list): + allowed_roles = [] + + channel = { + "channel_id": uuid.uuid4().hex[:12], + "practice_id": s["practice_id"], + "name": name, + "scope": scope, + "channel_type": channel_type, + "allowed_roles": allowed_roles, + "connection_id": body.get("connection_id", ""), + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "created_by": s["user_id"], + } + channels = _load_channels() + channels.append(channel) + _save_channels(channels) + return JSONResponse(content={"success": True, "channel": channel}) + + +@router.post("/channels/{channel_id}/update") +async def channels_update(channel_id: str, request: Request): + """Kanal aktualisieren (nur Admin).""" + s = _require_admin(request) + try: + body = await request.json() + except Exception: + body = {} + channels = _load_channels() + target = None + for c in channels: + if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]: + target = c + break + if not target: + raise HTTPException(status_code=404, detail="Kanal nicht gefunden") + if "name" in body: + new_name = (body["name"] or "").strip() + if new_name: + target["name"] = new_name + if "allowed_roles" in body: + ar = body["allowed_roles"] + if isinstance(ar, list): + target["allowed_roles"] = ar + _save_channels(channels) + return JSONResponse(content={"success": True, "channel": target}) + + +@router.delete("/channels/{channel_id}") +async def channels_delete(channel_id: str, request: Request): + """Kanal loeschen (nur Admin, keine Default-Kanaele).""" + s = _require_admin(request) + channels = _load_channels() + target = None + for c in channels: + if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]: + target = c + break + if not target: + raise HTTPException(status_code=404, detail="Kanal nicht gefunden") + default_names = {d["name"] for d in _DEFAULT_CHANNEL_DEFS} + if target.get("name") in default_names and target.get("scope") == "internal": + raise HTTPException(status_code=400, + detail="Standard-Kanaele koennen nicht geloescht werden") + channels = [c for c in channels if c.get("channel_id") != channel_id] + _save_channels(channels) + return JSONResponse(content={"success": True, "deleted": channel_id}) + + +# ===================================================================== +# FEDERATION ENDPOINTS (Praxis-zu-Praxis-Verbindungen) +# ===================================================================== + +@router.post("/federation/invite") +async def federation_invite(request: Request): + """Einladung zur Praxis-Verbindung erstellen.""" + s = _require_admin(request) + try: + body = await request.json() + except Exception: + body = {} + pid = s["practice_id"] + practices = _load_practices() + practice_name = practices.get(pid, {}).get("name", "Unbekannte Praxis") + + conn = { + "connection_id": uuid.uuid4().hex[:12], + "practice_a_id": pid, + "practice_b_id": "", + "status": "pending", + "invite_token": secrets.token_urlsafe(24), + "created_by": s["user_id"], + "accepted_by": "", + "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "accepted_at": "", + "revoked_at": "", + "practice_a_name": practice_name, + "practice_b_name": "", + "message": (body.get("message") or "").strip(), + } + conns = _load_connections() + conns.append(conn) + _save_connections(conns) + return JSONResponse(content={ + "success": True, + "connection_id": conn["connection_id"], + "invite_token": conn["invite_token"], + }) + + +@router.post("/federation/accept") +async def federation_accept(request: Request): + """Verbindungseinladung annehmen.""" + s = _require_admin(request) + try: + body = await request.json() + except Exception: + body = {} + invite_token = (body.get("invite_token") or "").strip() + if not invite_token: + raise HTTPException(status_code=400, detail="invite_token erforderlich") + + conns = _load_connections() + target = None + for c in conns: + if c.get("invite_token") == invite_token and c.get("status") == "pending": + target = c + break + if not target: + raise HTTPException(status_code=404, detail="Einladung nicht gefunden oder bereits verwendet") + + pid_b = s["practice_id"] + if target["practice_a_id"] == pid_b: + raise HTTPException(status_code=400, detail="Kann eigene Einladung nicht annehmen") + + practices = _load_practices() + practice_b_name = practices.get(pid_b, {}).get("name", "Unbekannte Praxis") + + now = time.strftime("%Y-%m-%d %H:%M:%S") + target["practice_b_id"] = pid_b + target["practice_b_name"] = practice_b_name + target["status"] = "active" + target["accepted_by"] = s["user_id"] + target["accepted_at"] = now + _save_connections(conns) + + channel_name = f"{target['practice_a_name']} \u2194 {practice_b_name}" + conn_id = target["connection_id"] + + channels = _load_channels() + for practice_id in (target["practice_a_id"], pid_b): + channels.append({ + "channel_id": uuid.uuid4().hex[:12], + "practice_id": practice_id, + "name": channel_name, + "scope": "external", + "channel_type": "external", + "allowed_roles": [], + "connection_id": conn_id, + "created": now, + "created_by": s["user_id"], + }) + _save_channels(channels) + + return JSONResponse(content={ + "success": True, + "connection_id": conn_id, + "practice_a": target["practice_a_name"], + "practice_b": practice_b_name, + }) + + +@router.get("/federation/connections") +async def federation_connections(request: Request): + """Alle Verbindungen der eigenen Praxis anzeigen.""" + s = _require_admin(request) + pid = s["practice_id"] + conns = _load_connections() + result = [c for c in conns + if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid] + return JSONResponse(content={"success": True, "connections": result}) + + +@router.post("/federation/connections/{connection_id}/revoke") +async def federation_revoke(connection_id: str, request: Request): + """Verbindung widerrufen / trennen.""" + s = _require_admin(request) + pid = s["practice_id"] + conns = _load_connections() + target = None + for c in conns: + if c.get("connection_id") == connection_id: + if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid: + target = c + break + if not target: + raise HTTPException(status_code=404, detail="Verbindung nicht gefunden") + if target["status"] == "revoked": + raise HTTPException(status_code=400, detail="Verbindung bereits widerrufen") + target["status"] = "revoked" + target["revoked_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + _save_connections(conns) + return JSONResponse(content={"success": True, "connection_id": connection_id, "status": "revoked"}) + + +@router.get("/federation/practices") +async def federation_practices(request: Request): + """Verbundene Praxen anzeigen (fuer alle authentifizierten Benutzer).""" + s = _require_session(request) + pid = s["practice_id"] + conns = _load_connections() + result = [] + for c in conns: + if c.get("status") != "active": + continue + if c.get("practice_a_id") == pid: + result.append({ + "practice_id": c.get("practice_b_id"), + "practice_name": c.get("practice_b_name", ""), + "connection_id": c.get("connection_id"), + "status": c.get("status"), + }) + elif c.get("practice_b_id") == pid: + result.append({ + "practice_id": c.get("practice_a_id"), + "practice_name": c.get("practice_a_name", ""), + "connection_id": c.get("connection_id"), + "status": c.get("status"), + }) + return JSONResponse(content={"success": True, "practices": result}) + + # ===================================================================== # CLEANUP + PRACTICE INFO # ===================================================================== @@ -747,6 +1619,7 @@ async def empfang_practice_info(request: Request): } if s and s.get("role") == "admin": result["invite_code"] = p.get("invite_code", "") + result["admin_email"] = p.get("admin_email", "") return JSONResponse(content=result) diff --git a/AzA march 2026/handover.md b/AzA march 2026/handover.md index 45c699f..c837464 100644 --- a/AzA march 2026/handover.md +++ b/AzA march 2026/handover.md @@ -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 --- diff --git a/AzA march 2026/project_status.json b/AzA march 2026/project_status.json index 29636c8..45a9b92 100644 --- a/AzA march 2026/project_status.json +++ b/AzA march 2026/project_status.json @@ -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.", diff --git a/AzA march 2026/translate.py b/AzA march 2026/translate.py index 870cd86..302cee0 100644 --- a/AzA march 2026/translate.py +++ b/AzA march 2026/translate.py @@ -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("<>", _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(): diff --git a/AzA march 2026/web/empfang.html b/AzA march 2026/web/empfang.html index 1f7b222..951e386 100644 --- a/AzA march 2026/web/empfang.html +++ b/AzA march 2026/web/empfang.html @@ -167,6 +167,67 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra .copied-flash{animation:copiedFl .6s ease} @keyframes copiedFl{0%{background:#d4edda}100%{background:transparent}} +/* === Login Overlay === */ +#login-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#f0f4f8;z-index:200;display:flex;align-items:center;justify-content:center} +#login-overlay.hidden{display:none} +.login-box{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12);padding:36px;width:380px;max-width:90vw} +.login-box h2{font-size:1.3rem;color:#1a4d6d;margin-bottom:6px} +.login-box p{font-size:.85rem;color:#6a8a9a;margin-bottom:20px} +.login-field{margin-bottom:14px} +.login-field label{display:block;font-size:.82rem;color:#3a5a7a;margin-bottom:4px;font-weight:600} +.login-field input,.login-field select{width:100%;border:1px solid #d0dce8;border-radius:6px;padding:8px 12px;font-size:.9rem;font-family:inherit;outline:none;transition:border-color .15s} +.login-field input:focus,.login-field select:focus{border-color:#5B8DB3} +.login-btn{width:100%;background:#5B8DB3;color:#fff;border:none;border-radius:8px;padding:10px;font-size:.95rem;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s} +.login-btn:hover{background:#4A7A9E} +.login-error{color:#dc3545;font-size:.82rem;margin-top:8px;min-height:1.2em} +.login-switch{text-align:center;margin-top:14px;font-size:.82rem;color:#6a8a9a} +.login-switch a{color:#5B8DB3;cursor:pointer;text-decoration:underline} + +/* === Admin Panel === */ +.admin-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.3);z-index:150;align-items:flex-start;justify-content:center;padding-top:40px;overflow-y:auto} +.admin-overlay.open{display:flex} +.admin-panel{background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.15);width:720px;max-width:94vw;max-height:calc(100vh - 80px);overflow-y:auto;padding:0} +.admin-header{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid #e0e4e8;position:sticky;top:0;background:#fff;border-radius:12px 12px 0 0;z-index:5} +.admin-header h2{font-size:1.15rem;color:#1a4d6d;font-weight:600} +.admin-close{background:none;border:none;font-size:1.3rem;color:#8a9aaa;cursor:pointer;padding:4px 8px;border-radius:4px} +.admin-close:hover{background:#f0f4f8;color:#1a4d6d} +.admin-tabs{display:flex;gap:0;border-bottom:1px solid #e0e4e8;padding:0 24px;background:#fafcfe} +.admin-tab{padding:10px 18px;font-size:.85rem;color:#6a8a9a;cursor:pointer;border-bottom:2px solid transparent;font-weight:500;transition:all .15s;font-family:inherit;background:none;border-top:none;border-left:none;border-right:none} +.admin-tab:hover{color:#1a4d6d;background:#f0f4f8} +.admin-tab.active{color:#5B8DB3;border-bottom-color:#5B8DB3;font-weight:600} +.admin-body{padding:20px 24px} +.admin-table{width:100%;border-collapse:collapse;font-size:.82rem} +.admin-table th{text-align:left;padding:8px 10px;color:#5B8DB3;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.4px;border-bottom:2px solid #e0e4e8} +.admin-table td{padding:8px 10px;border-bottom:1px solid #f0f4f8;vertical-align:middle} +.admin-table tr:hover td{background:#fafcfe} +.admin-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:.72rem;font-weight:600} +.admin-badge.admin{background:#e8d5f5;color:#6b3fa0} +.admin-badge.arzt{background:#d4edda;color:#155724} +.admin-badge.mpa{background:#d6eaf8;color:#1a5276} +.admin-badge.empfang{background:#fff3cd;color:#856404} +.admin-badge.active{background:#d4edda;color:#155724} +.admin-badge.deactivated{background:#f8d7da;color:#721c24} +.admin-badge.trusted{background:#d4edda;color:#155724} +.admin-badge.blocked{background:#f8d7da;color:#721c24} +.admin-badge.pending_conn{background:#fff3cd;color:#856404} +.admin-badge.active_conn{background:#d4edda;color:#155724} +.admin-badge.revoked{background:#e2e3e5;color:#6c757d} +.admin-btn{background:#e8f0f8;color:#2a5a8a;border:1px solid #d0dce8;border-radius:4px;padding:3px 10px;font-size:.75rem;cursor:pointer;font-family:inherit;transition:all .12s;white-space:nowrap} +.admin-btn:hover{background:#d4e4f0} +.admin-btn.danger{color:#8a2a2a;background:#f8e8e8;border-color:#e8c8c8} +.admin-btn.danger:hover{background:#f0d4d4} +.admin-btn.success{color:#155724;background:#d4edda;border-color:#c3e6cb} +.admin-btn.success:hover{background:#c3e6cb} +.admin-section-title{font-size:.88rem;font-weight:600;color:#1a4d6d;margin:16px 0 8px} +.admin-info-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:.82rem;flex-wrap:wrap} +.admin-info-label{color:#6a8a9a;min-width:120px} +.admin-info-value{color:#1a3a5a;font-weight:500} +.admin-mono{font-family:monospace;background:#f0f4f8;padding:2px 8px;border-radius:4px;user-select:all} +.admin-empty{text-align:center;padding:24px;color:#8a9aaa;font-size:.85rem} +.admin-add-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap;align-items:center} +.admin-add-row input,.admin-add-row select{border:1px solid #d0dce8;border-radius:4px;padding:4px 8px;font-size:.82rem;font-family:inherit} +.admin-add-row input{flex:1;min-width:120px} + @media print{ header,.settings-panel,#sidebar,#tasks-panel,.status-bar,#chat-input-bar,#chat-top-bar{display:none!important} #app-layout{display:block!important} @@ -178,6 +239,10 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra +
+ +
+

AZA – Empfang

@@ -219,18 +284,8 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra Sek.
-

Benutzerverwaltung

-
-
- - - -
+

Praxis-Information

+

Aufbewahrung

@@ -238,6 +293,12 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
+ +
+ +
@@ -247,8 +308,11 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra