From de8a7284d0cf8c93870628f2b8102a76436ef6af Mon Sep 17 00:00:00 2001 From: suro Date: Tue, 21 Apr 2026 10:00:36 +0200 Subject: [PATCH] update --- AzA march 2026/aza_launcher.py | 38 +- AzA march 2026/backend_main.py | 71 +- AzA march 2026/basis14.py | 857 ++++++++++++----- AzA march 2026/empfang_app_settings.json | 7 + AzA march 2026/empfang_routes.py | 885 ++++++++++++++---- AzA march 2026/kg_diktat_autotext.json | 12 +- AzA march 2026/kg_diktat_button_heat.json | 7 +- AzA march 2026/kg_diktat_diktat_window.txt | 2 +- .../kg_diktat_launcher_geometry.txt | 2 +- AzA march 2026/kg_diktat_token_usage.txt | 2 +- AzA march 2026/kg_diktat_user_profile.json | 7 +- AzA march 2026/kg_diktat_window.txt | 2 +- AzA march 2026/license_status_cache.json | 2 +- AzA march 2026/paned_positions.json | 4 +- AzA march 2026/stripe_routes.py | 96 +- AzA march 2026/web/empfang.html | 263 +++++- 16 files changed, 1772 insertions(+), 485 deletions(-) create mode 100644 AzA march 2026/empfang_app_settings.json diff --git a/AzA march 2026/aza_launcher.py b/AzA march 2026/aza_launcher.py index 4d25348..c70db2f 100644 --- a/AzA march 2026/aza_launcher.py +++ b/AzA march 2026/aza_launcher.py @@ -34,7 +34,7 @@ from aza_style import ( _MODULE_DESCRIPTIONS = { "ki": "Medizinische Fragen stellen,\nBefunde besprechen, Zweitmeinung einholen", - "kg": "Diktat aufnehmen, transkribieren\nund Krankengeschichte erstellen", + "kg": "Diktat aufnehmen und in Krankengeschichte umwandeln", "empfang": "Empfangs-Chat, Aufgaben\nund Praxis-Kommunikation", "notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag", "translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen", @@ -69,6 +69,7 @@ def _draw_module_icon(c: tk.Canvas, key: str): fill=fg, outline="") elif key == "kg": + # Nur Fallback wenn logo.png fehlt (Kachel nutzt sonst echtes Logo als PhotoImage) c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg) elif key == "notizen": @@ -174,6 +175,7 @@ class AzaLauncher(tk.Tk): self.attributes("-topmost", True) self._logo_img = None + self._kg_tile_icon = None # gleiches logo.png wie Header, 38×38 für AzA-Office-Kachel try: import sys as _sys _search = [] @@ -190,8 +192,17 @@ class AzaLauncher(tk.Tk): break if logo_path: from PIL import Image, ImageTk - img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS) - self._logo_img = ImageTk.PhotoImage(img, master=self) + _pil = Image.open(logo_path) + if _pil.mode not in ("RGB", "RGBA"): + _pil = _pil.convert("RGBA") + self._logo_img = ImageTk.PhotoImage( + _pil.resize((82, 82), Image.Resampling.LANCZOS), + master=self, + ) + self._kg_tile_icon = ImageTk.PhotoImage( + _pil.resize((_ICON_SZ, _ICON_SZ), Image.Resampling.LANCZOS), + master=self, + ) except Exception: pass @@ -248,12 +259,12 @@ 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", - font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG, + font=(FONT_FAMILY, 19, "bold"), fg="#1a4d6d", bg=BG, cursor="hand2") aza_lbl.pack(anchor="w") aza_lbl.bind("", self._open_admin) - tk.Label(title_block, text="Medizinischer KI-Arbeitsplatz", - font=(FONT_FAMILY, 10), fg=SUBTLE, bg=BG + tk.Label(title_block, text="von Arzt zu Arzt", + font=(FONT_FAMILY, 11), fg="#1a4d6d", bg=BG ).pack(anchor="w") self._build_capacity_bar(header) @@ -528,14 +539,15 @@ class AzaLauncher(tk.Tk): top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor) top_row.pack(fill="x", pady=(0, 10)) - 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") + icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ, + bg=icon_color, highlightthickness=0, cursor=_cursor) + icon_cv.pack(side="left") + if mod_key == "kg" and getattr(self, "_kg_tile_icon", None) is not None: + icon_cv.create_image( + _ICON_SZ // 2, _ICON_SZ // 2, + image=self._kg_tile_icon, + ) 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"), diff --git a/AzA march 2026/backend_main.py b/AzA march 2026/backend_main.py index 0104484..0089cf9 100644 --- a/AzA march 2026/backend_main.py +++ b/AzA march 2026/backend_main.py @@ -1601,6 +1601,19 @@ def telemetry_ping(data: TelemetryPing, request: Request): return {"status": "ok"} +def _sync_empfang_practice_record(practice_id: str, admin_email: str, display_name: str) -> None: + """Legt die Praxis in empfang_practices.json an (idempotent).""" + try: + from empfang_routes import _ensure_practice + except Exception as exc: + print(f"[LICENSE] empfang_routes import failed: {exc}") + return + try: + _ensure_practice(practice_id, name=display_name, admin_email=admin_email or "") + except Exception as exc: + print(f"[LICENSE] _ensure_practice failed: {exc}") + + @app.get("/admin/telemetry/stats") def telemetry_stats(): uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds()) @@ -1664,6 +1677,7 @@ def license_status( status = None current_period_end = None customer_email = None + license_practice_id: Optional[str] = None try: try: import stripe_routes # type: ignore @@ -1677,7 +1691,7 @@ def license_status( if license_key and license_key.strip(): row = con.execute( """ - SELECT status, current_period_end, customer_email + SELECT status, current_period_end, customer_email, practice_id FROM licenses WHERE upper(license_key) = ? ORDER BY updated_at DESC @@ -1688,7 +1702,7 @@ def license_status( if row is None and email and email.strip(): row = con.execute( """ - SELECT status, current_period_end, customer_email + SELECT status, current_period_end, customer_email, practice_id FROM licenses WHERE lower(customer_email) = ? ORDER BY updated_at DESC @@ -1697,29 +1711,17 @@ def license_status( (email.strip().lower(),), ).fetchone() if row is None: - n_active = con.execute( - "SELECT COUNT(*) FROM licenses WHERE status = 'active'" - ).fetchone()[0] - if n_active == 1: - row = con.execute( - """ - SELECT status, current_period_end, customer_email - FROM licenses - WHERE status = 'active' - LIMIT 1 - """ - ).fetchone() - print(f"[LICENSE-STATUS] fallback: single active license -> {row[2] if row else 'none'}") - else: - print(f"[LICENSE-STATUS] fallback: {n_active} active licenses, no auto-select") + print("[LICENSE-STATUS] keine eindeutige Lizenzzeile (license_key oder customer_email erforderlich)") if row: status = row[0] current_period_end = int(row[1]) if row[1] is not None else None customer_email = str(row[2]).strip() if row[2] is not None else None + license_practice_id = str(row[3]).strip() if len(row) > 3 and row[3] else None except Exception: status = None current_period_end = None customer_email = None + license_practice_id = None decision = compute_license_decision(current_period_end=current_period_end, status=status) @@ -1736,11 +1738,14 @@ def license_status( "used_devices": 0, "device_allowed": True, "reason": "ok", + "practice_id": license_practice_id or None, } + dev_user_key = license_practice_id.strip() if license_practice_id and license_practice_id.strip() else "default" + if device_id and customer_email: dd = enforce_and_touch_device( - customer_email=customer_email, user_key="default", + customer_email=customer_email, user_key=dev_user_key, device_id=device_id, db_path=str(db_path), device_name=device_name, app_version=app_version, device_fingerprint=device_fingerprint, @@ -1784,7 +1789,7 @@ def license_activate( row = con.execute( """ SELECT subscription_id, status, current_period_end, customer_email, - allowed_users, devices_per_user + allowed_users, devices_per_user, practice_id FROM licenses WHERE upper(license_key) = ? ORDER BY updated_at DESC @@ -1796,9 +1801,30 @@ def license_activate( if not row: raise HTTPException(status_code=404, detail="Lizenzschluessel ungueltig.") - sub_id, status, cpe, cust_email, au, dpu = row + sub_id, status, cpe, cust_email, au, dpu = row[:6] + practice_id_raw = row[6] if len(row) > 6 else None current_period_end = int(cpe) if cpe is not None else None + practice_id = (str(practice_id_raw).strip() if practice_id_raw else "") or "" + if not practice_id: + practice_id = f"prac_{uuid.uuid4().hex[:12]}" + try: + with sqlite3.connect(db_path) as con: + con.execute( + """ + UPDATE licenses + SET practice_id = ?, updated_at = ? + WHERE subscription_id = ? + """, + (practice_id, int(time.time()), sub_id), + ) + con.commit() + except Exception as exc: + print(f"[LICENSE] practice_id update failed: {exc}") + + display_name = (str(cust_email).strip() if cust_email else "") or "Meine Praxis" + _sync_empfang_practice_record(practice_id, str(cust_email or "").strip(), display_name) + decision = compute_license_decision(current_period_end=current_period_end, status=status) device_id = request.headers.get("X-Device-Id") @@ -1823,11 +1849,14 @@ def license_activate( "used_devices": 0, "device_allowed": True, "reason": "ok", + "practice_id": practice_id, } + dev_user_key = practice_id.strip() if practice_id.strip() else "default" + if cust_email: dd = enforce_and_touch_device( - customer_email=cust_email, user_key="default", + customer_email=cust_email, user_key=dev_user_key, device_id=device_id, db_path=str(db_path), device_name=device_name, app_version=app_version, device_fingerprint=device_fingerprint, diff --git a/AzA march 2026/basis14.py b/AzA march 2026/basis14.py index aa0ad81..e4eb2d8 100644 --- a/AzA march 2026/basis14.py +++ b/AzA march 2026/basis14.py @@ -599,6 +599,8 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin self._empfang_last_seen_ids = set() self.after(5000, self._empfang_background_poll) + self.after(3000, self._provision_empfang_account) + self._audio_notiz_autostart_attempted = False self.after(1200, self._open_dev_status_window) @@ -633,7 +635,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin "notizen": lambda: self._start_audio_notiz_addon(silent=False), "translator": self._open_uebersetzer, "medwork_chat": self._open_docapp, - "praxis_chat": self._open_docapp, + "praxis_chat": self._send_to_empfang, } handler = dispatch.get(mod) if handler is None: @@ -643,7 +645,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin "Sie können es manuell über die Seitenleiste starten.", ) return - if mod in ("notizen", "translator", "medwork_chat", "praxis_chat"): + if mod in ("notizen", "translator", "medwork_chat"): if not self._check_ai_consent(): return if mod in ("translator", "notizen", "medwork_chat", "praxis_chat"): @@ -757,6 +759,11 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin resp_valid_until = data.get("valid_until") if isinstance(data, dict) else None payload = {"valid": resp_valid, "valid_until": resp_valid_until, "cached_at": time.time()} _save_license_cache(payload) + _srv_pid = (data.get("practice_id") or "").strip() if isinstance(data, dict) else "" + if _srv_pid and _srv_pid != self.get_practice_id(): + self._user_profile["practice_id"] = _srv_pid + save_user_profile(self._user_profile) + print(f"[LICENSE] practice_id aus Status gespeichert: {_srv_pid[:8]}...") now = int(time.time()) valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None @@ -2702,18 +2709,10 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin self.wait_window(dlg) def _show_license_required_notice(self): - """Zeigt beim Start im Demo-Modus bei Remote-Backend einen Lizenzhinweis.""" + """Zeigt beim Start im Demo-Modus direkt den Aktivierungsdialog.""" if self.license_mode == "active": return - from tkinter import messagebox - messagebox.showinfo( - "AZA – Lizenz erforderlich", - "Willkommen bei AZA.\n\n" - "Sie nutzen aktuell die eingeschraenkte Demo-Version.\n" - f"In der Demo koennen Sie bis zu {DEMO_MAX_DICTATIONS} Diktate testen.\n\n" - "Fuer die Vollversion aktivieren Sie bitte Ihren\n" - "Lizenzschluessel ueber das Schluessel-Symbol \U0001F511 in der App.", - ) + self._show_activation_dialog() def _show_device_limit_notice(self): """Zeigt beim Start eine Meldung wenn das Gerätelimit erreicht ist.""" @@ -2724,7 +2723,7 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin messagebox.showwarning("AZA – Geraete-Limit", msg) def _show_activation_dialog(self): - """Dialog zum Eingeben/Aktivieren eines Lizenzschluessels ueber das Backend.""" + """Einziger Aktivierungsdialog: Demo-Hinweis + Lizenzschluessel-Eingabe.""" saved_key = (self._user_profile.get("license_key") or "").strip() _has_remote = False @@ -2735,46 +2734,75 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin except Exception: pass - status_text = "Vollversion aktiv" if self.license_mode == "active" else "Testversion" - status_color = "#2a7a3a" if self.license_mode == "active" else "#c04040" + is_active = self.license_mode == "active" dlg = tk.Toplevel(self) - dlg.title("AZA Aktivierung") + dlg.title("AzA Aktivierung") dlg.configure(bg="#E8F4FA") dlg.resizable(True, True) - dlg.geometry("480x340") - dlg.minsize(420, 300) + dlg.geometry("520x420") + dlg.minsize(460, 360) dlg.attributes("-topmost", True) self._register_window(dlg) - center_window(dlg, 480, 340) + center_window(dlg, 520, 420) - tk.Label(dlg, text="AZA Lizenz aktivieren", font=("Segoe UI", 14, "bold"), - bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10) + tk.Label(dlg, text="AzA Lizenz", font=("Segoe UI", 14, "bold"), + bg="#5B8DB3", fg="white").pack(fill="x", ipady=10) - tk.Label(dlg, text=f"Status: {status_text}", font=("Segoe UI", 10), - bg="#E8F4FA", fg=status_color).pack(fill="x", padx=16, pady=(10, 2)) + info = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=10) + info.pack(fill="x") - form = tk.Frame(dlg, bg="#E8F4FA", padx=16, pady=8) + if is_active: + tk.Label(info, text="\u2713 Vollversion aktiv", font=("Segoe UI", 10, "bold"), + bg="#E8F4FA", fg="#2a7a3a").pack(anchor="w") + if saved_key: + tk.Label(info, text=f"Lizenz: {saved_key}", font=("Consolas", 9), + bg="#E8F4FA", fg="#5a7a8a").pack(anchor="w", pady=(2, 0)) + else: + tk.Label(info, text="Testversion", font=("Segoe UI", 10, "bold"), + bg="#E8F4FA", fg="#c04040").pack(anchor="w") + tk.Label(info, text=f"In der Demo koennen Sie bis zu {DEMO_MAX_DICTATIONS} Diktate testen.\n" + "Fuer die Vollversion geben Sie bitte Ihren Lizenzschluessel ein.", + font=("Segoe UI", 9), bg="#E8F4FA", fg="#5a7a8a", + wraplength=440, justify="left").pack(anchor="w", pady=(4, 0)) + + form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8) form.pack(fill="x") - tk.Label(form, text="Lizenzschluessel:", font=("Segoe UI", 10, "bold"), + tk.Label(form, text="Lizenzschluessel:", font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w") tk.Label(form, text="Format: AZA-XXXX-XXXX-XXXX-XXXX", font=("Segoe UI", 8), - bg="#E8F4FA", fg="#888").pack(anchor="w") - key_entry = tk.Entry(form, font=("Consolas", 14), bg="white", fg="#1a4d6d", - relief="flat", bd=1, justify="center") + bg="#E8F4FA", fg="#aaa").pack(anchor="w") + key_entry = tk.Entry(form, font=("Consolas", 12), bg="white", fg="#1a4d6d", + relief="solid", bd=1, justify="center") key_entry.pack(fill="x", ipady=6, pady=(4, 6)) if saved_key: key_entry.insert(0, saved_key) - result_label = tk.Label(form, text="", font=("Segoe UI", 9), - bg="#E8F4FA", fg="#888888", wraplength=420, justify="left") - result_label.pack(fill="x") + def _paste_from_clipboard(event=None): + try: + clip = dlg.clipboard_get().strip() + if clip: + key_entry.delete(0, "end") + key_entry.insert(0, clip) + except tk.TclError: + pass + return "break" + + _ctx_menu = tk.Menu(dlg, tearoff=0, font=("Segoe UI", 9)) + _ctx_menu.add_command(label="Einfuegen", command=_paste_from_clipboard) + _ctx_menu.add_command(label="Alles auswaehlen", + command=lambda: key_entry.select_range(0, "end")) + key_entry.bind("", lambda e: _ctx_menu.tk_popup(e.x_root, e.y_root)) + + status_label = tk.Label(form, text="", font=("Segoe UI", 9), + bg="#E8F4FA", fg="#888", wraplength=440, justify="left") + status_label.pack(fill="x", pady=(2, 0)) def do_activate(): k = key_entry.get().strip().upper() if not k: - result_label.configure(text="Bitte Lizenzschluessel eingeben.", fg="#E05050") + status_label.configure(text="Bitte Lizenzschluessel eingeben.", fg="#E05050") return if not _has_remote: @@ -2782,95 +2810,93 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin valid, expiry, reason = _val_key(k) if valid: _save_key(k) - result_label.configure(text=f"Offline gespeichert: {reason}", fg="#2a7a3a") + status_label.configure(text=f"Offline gespeichert: {reason}", fg="#2a7a3a") else: - result_label.configure(text=reason, fg="#E05050") + status_label.configure(text=reason, fg="#E05050") return - result_label.configure(text="Wird geprueft...", fg="#555") + status_label.configure(text="Wird geprueft...", fg="#5B8DB3") dlg.update_idletasks() - try: - backend_url = self.get_backend_url() - api_token = self.get_backend_token() - device_id = _get_or_create_device_id() - _dn = f"{platform.node() or 'unknown'}" + def _worker(): try: - from aza_version import APP_VERSION as _av - except Exception: - _av = "" - resp = requests.post( - f"{backend_url}/license/activate", - json={"license_key": k}, - headers={ - "X-API-Token": api_token, - "X-Device-Id": device_id, - "X-Device-Name": _dn, - "X-App-Version": _av, - "X-Device-Fingerprint": _get_hardware_fingerprint(), - }, - timeout=10, - ) - if resp.status_code == 200: - data = resp.json() - _is_active = ( - data.get("valid") - or data.get("license_active") - or (isinstance(data, dict) and str(data.get("status", "")).lower() == "active") - ) - if _is_active: - self._user_profile["license_key"] = k - if data.get("customer_email"): - self._user_profile["email"] = data["customer_email"] - save_user_profile(self._user_profile) - self.license_mode = "active" - _save_license_cache({ - "valid": True, - "valid_until": data.get("valid_until"), - "cached_at": time.time(), - }) - result_label.configure( - text="Lizenz erfolgreich aktiviert! Bitte App neu starten.", - fg="#2a7a3a", - ) - self.set_status("Lizenz aktiviert – Vollversion") - self.title("AzA Office") - else: - result_label.configure( - text=f"Lizenz inaktiv (Status: {data.get('status', 'unbekannt')}). " - "Bitte pruefen Sie Ihr Abonnement.", - fg="#E05050", - ) - elif resp.status_code == 404: - result_label.configure(text="Lizenzschluessel ungueltig.", fg="#E05050") - elif resp.status_code == 403: - _detail_403 = "" + backend_url = self.get_backend_url() + api_token = self.get_backend_token() + device_id = _get_or_create_device_id() + _dn = f"{platform.node() or 'unknown'}" try: - _detail_403 = resp.json().get("detail", "") + from aza_version import APP_VERSION as _av except Exception: - pass - result_label.configure( - text=_detail_403 or "Geraete-Limit erreicht.", - fg="#E05050", + _av = "" + resp = requests.post( + f"{backend_url}/license/activate", + json={"license_key": k}, + headers={ + "X-API-Token": api_token, + "X-Device-Id": device_id, + "X-Device-Name": _dn, + "X-App-Version": _av, + "X-Device-Fingerprint": _get_hardware_fingerprint(), + }, + timeout=10, ) - else: - result_label.configure( - text=f"Serverfehler ({resp.status_code}). Bitte spaeter erneut versuchen.", - fg="#E05050", - ) - except requests.RequestException as e: - result_label.configure( - text=f"Verbindungsfehler: {e}", - fg="#E05050", - ) + if resp.status_code == 200: + data = resp.json() + _is_active = ( + data.get("valid") + or data.get("license_active") + or (isinstance(data, dict) and str(data.get("status", "")).lower() == "active") + ) + if _is_active: + self._user_profile["license_key"] = k + if data.get("customer_email"): + self._user_profile["email"] = data["customer_email"] + _pid = (data.get("practice_id") or "").strip() if isinstance(data, dict) else "" + if _pid: + self._user_profile["practice_id"] = _pid + save_user_profile(self._user_profile) + self.license_mode = "active" + _save_license_cache({ + "valid": True, + "valid_until": data.get("valid_until"), + "cached_at": time.time(), + }) + self.after(0, lambda: status_label.configure( + text="\u2713 Lizenz erfolgreich aktiviert!", fg="#2a7a3a")) + self.after(0, lambda: self.set_status("Lizenz aktiviert \u2013 Vollversion")) + self.after(0, lambda: self.title("AzA Office")) + self.after(0, self._provision_empfang_account) + else: + self.after(0, lambda: status_label.configure( + text=f"Lizenz inaktiv ({data.get('status', '?')}). Bitte Abo pruefen.", + fg="#E05050")) + elif resp.status_code == 404: + self.after(0, lambda: status_label.configure( + text="Lizenzschluessel ungueltig.", fg="#E05050")) + elif resp.status_code == 403: + _d = "" + try: + _d = resp.json().get("detail", "") + except Exception: + pass + self.after(0, lambda: status_label.configure( + text=_d or "Geraete-Limit erreicht.", fg="#E05050")) + else: + self.after(0, lambda: status_label.configure( + text=f"Serverfehler ({resp.status_code}).", fg="#E05050")) + except requests.RequestException as e: + self.after(0, lambda: status_label.configure( + text=f"Verbindungsfehler: {e}", fg="#E05050")) + + threading.Thread(target=_worker, daemon=True).start() btn_frame = tk.Frame(dlg, bg="#E8F4FA") - btn_frame.pack(pady=(12, 8)) - tk.Button(btn_frame, text="Aktivieren", font=("Segoe UI", 12, "bold"), - bg="#27AE60", fg="white", activebackground="#219A52", + btn_frame.pack(pady=(12, 10)) + tk.Button(btn_frame, text="Aktivieren", font=("Segoe UI", 11, "bold"), + bg="#5B8DB3", fg="white", activebackground="#4A7A9E", relief="flat", bd=0, padx=24, pady=8, cursor="hand2", command=do_activate).pack(side="left", padx=8) - tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 11), + tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 10), bg="#C8DDE6", fg="#1a4d6d", relief="flat", bd=0, padx=16, pady=6, cursor="hand2", command=dlg.destroy).pack(side="left", padx=8) @@ -2881,11 +2907,11 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin dlg.title("Profil bearbeiten") dlg.configure(bg="#E8F4FA") dlg.resizable(True, True) - dlg.geometry("520x820") - dlg.minsize(460, 680) + dlg.geometry("780x1640") + dlg.minsize(690, 1360) dlg.attributes("-topmost", True) self._register_window(dlg) - center_window(dlg, 520, 820) + center_window(dlg, 780, 1640) tk.Label(dlg, text="\U0001f464 Profil bearbeiten", font=("Segoe UI", 13, "bold"), bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8) @@ -2966,33 +2992,414 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin 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)) + # --- Lizenzschlüssel (Software-Aktivierung) --- + lic_sep = tk.Frame(form, bg="#B9ECFA", height=1) + lic_sep.pack(fill="x", pady=(10, 6)) + tk.Label(form, text="\U0001f511 Lizenzschluessel", font=("Segoe UI", 10, "bold"), + bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 2)) + tk.Label(form, text="Ihr persoenlicher Lizenzschluessel fuer die Software-Aktivierung.", + font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(0, 4)) - 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") + _lic_key = (self._user_profile.get("license_key") or "").strip() + if _lic_key: + _lic_lbl = tk.Label(form, text=_lic_key, font=("Consolas", 10), + bg="#f0f4f8", fg="#1a3a5a", relief="flat", + padx=6, pady=4, 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."))) + add_tooltip(_lic_lbl, "Klick zum Kopieren") + else: + tk.Label(form, text="Keine Lizenz aktiviert.", font=("Segoe UI", 9), + bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(0, 6)) - pw_old_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR, + # --- Chat-Einladung (separater Code, nicht Lizenzschlüssel) --- + chat_sep = tk.Frame(form, bg="#B9ECFA", height=1) + chat_sep.pack(fill="x", pady=(10, 6)) + tk.Label(form, text="\U0001f4e8 Chat-Einladungscode", font=("Segoe UI", 10, "bold"), + bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 2)) + tk.Label(form, text="Teilen Sie diesen Code mit Kollegen, damit diese\n" + "Ihrem Praxis-Chat beitreten koennen.\n" + "Der Code ist getrennt vom Lizenzschluessel.", + font=("Segoe UI", 8), bg="#E8F4FA", fg="#888", + justify="left").pack(anchor="w", pady=(0, 4)) + + _invite_code_frame = tk.Frame(form, bg="#E8F4FA") + _invite_code_frame.pack(fill="x", pady=(0, 4)) + _invite_ent = tk.Entry( + _invite_code_frame, font=("Consolas", 12, "bold"), + bg="#f0f4f8", fg="#1a3a5a", relief="flat", + highlightthickness=1, highlightbackground="#d0dce8", + ) + _invite_ent.insert(0, "Wird geladen...") + _invite_ent.config(state="readonly") + _invite_ent.pack(fill="x", ipady=4) + + def _invite_code_text(): + try: + return (_invite_ent.get() or "").strip() + except Exception: + return "" + + def _set_invite_fields(code_txt: str, link_txt: str = "") -> None: + """Readonly-Felder; Text ist markierbar (Kopieren mit Maus/Ctrl+C).""" + _invite_ent.config(state="normal") + _invite_ent.delete(0, "end") + _invite_ent.insert(0, code_txt) + _invite_ent.config(state="readonly") + _link_ent.config(state="normal") + _link_ent.delete(0, "end") + _link_ent.insert(0, link_txt) + _link_ent.config(state="readonly") + + def _copy_invite_code(e=None): + code = _invite_code_text() + if code and code not in ("Wird geladen...", "Offline", "Nicht verfuegbar") and not code.startswith("Fehler"): + try: + dlg.clipboard_clear() + dlg.clipboard_append(code) + dlg.update_idletasks() + self.set_status("Chat-Einladungscode kopiert.") + except Exception: + self.set_status("Kopieren fehlgeschlagen.") + _invite_ent.bind("", _copy_invite_code) + add_tooltip(_invite_ent, "Markieren und kopieren, oder Klick fuer Zwischenablage") + + _link_frame = tk.Frame(form, bg="#E8F4FA") + _link_frame.pack(fill="x", pady=(0, 4)) + tk.Label(_link_frame, text="Einladungslink:", font=("Segoe UI", 9), + bg="#E8F4FA", fg="#5B8DB3").pack(anchor="w") + _link_ent = tk.Entry( + _link_frame, font=("Segoe UI", 9), + bg="#f0f4f8", fg="#1a3a5a", relief="flat", + highlightthickness=1, highlightbackground="#d0dce8", + ) + _link_ent.pack(fill="x", ipady=2) + _link_ent.config(state="readonly") + + _link_btn_row = tk.Frame(form, bg="#E8F4FA") + _link_btn_row.pack(fill="x", pady=(4, 8)) + + def _copy_invite_link(): + try: + text = (_link_ent.get() or "").strip() + if text: + dlg.clipboard_clear() + dlg.clipboard_append(text) + dlg.update_idletasks() + self.set_status("Einladungslink kopiert.") + except Exception: + self.set_status("Kopieren fehlgeschlagen.") + + def _send_invite_message(): + code = _invite_code_text() + try: + link = (_link_ent.get() or "").strip() + except Exception: + link = "" + clinic = self._user_profile.get("clinic", "Praxis") + name = self._user_profile.get("name", "") + msg_parts = [ + f"Einladung zum Praxis-Chat von {clinic}", + "", + f"Chat-Einladungscode: {code}" if code else "", + f"Einladungslink: {link}" if link else "", + "", + "Oeffnen Sie den Einladungslink oder geben Sie", + "den Einladungscode im Praxis-Chat ein,", + "um dem Chat beizutreten.", + ] + if name: + msg_parts += ["", f"Absender: {name}"] + full_msg = "\n".join(l for l in msg_parts if l) + if not full_msg.strip(): + self.set_status("Kein Einladungscode geladen – bitte Verbindung pruefen.") + return + try: + dlg.clipboard_clear() + dlg.clipboard_append(full_msg) + dlg.update_idletasks() + self.set_status("Einladungstext in Zwischenablage kopiert.") + except Exception: + self.set_status("Kopieren fehlgeschlagen.") + + def _regenerate_code(): + if not messagebox.askyesno( + "Code erneuern", + "Neuen Chat-Einladungscode erstellen?\n\n" + "Der alte Code wird ungueltig.\n" + "Bereits registrierte Benutzer bleiben erhalten.", + parent=dlg): + return + def _worker(): + try: + bu = self.get_backend_url() + r = requests.post( + f"{bu}/empfang/auth/regenerate_invite", + headers=self._empfang_headers(), timeout=5) + if r.status_code == 200: + new_code = r.json().get("invite_code", "") + link = f"https://empfang.aza-medwork.ch/?invite={new_code}" + self.after(0, lambda nc=new_code, lk=link: _set_invite_fields(nc, lk)) + self.after(0, lambda: self.set_status("Neuer Einladungscode erstellt.")) + else: + self.after(0, lambda: messagebox.showerror( + "Fehler", "Code konnte nicht erneuert werden.", parent=dlg)) + except Exception as exc: + self.after(0, lambda m=str(exc): messagebox.showerror( + "Fehler", m, parent=dlg)) + threading.Thread(target=_worker, daemon=True).start() + + 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)) + tk.Button(_link_btn_row, text="Einladung kopieren", font=("Segoe UI", 9), + bg="#5B8DB3", fg="white", relief="flat", padx=10, pady=3, + cursor="hand2", command=_send_invite_message).pack(side="left", padx=(0, 6)) + tk.Button(_link_btn_row, text="\u21bb Neuer Code", font=("Segoe UI", 9), + bg="#e8f0f8", fg="#2a5a8a", relief="flat", padx=10, pady=3, + cursor="hand2", command=_regenerate_code).pack(side="left") + + def _load_invite_info(): + try: + bu = self.get_backend_url() + r = requests.get(f"{bu}/empfang/practice/info", + headers=self._empfang_headers(), timeout=12) + if r.status_code == 200: + d = r.json() + code = (d.get("invite_code") or "").strip() + if code: + link = f"https://empfang.aza-medwork.ch/?invite={code}" + self.after(0, lambda c=code, l=link: _set_invite_fields(c, l)) + else: + self.after(0, lambda: _set_invite_fields( + "Nicht verfuegbar (kein Code)", "")) + else: + detail = str(r.status_code) + try: + dj = r.json() + if isinstance(dj, dict) and dj.get("detail"): + detail = str(dj["detail"]) + except Exception: + pass + err = f"Fehler ({detail})" + self.after(0, lambda t=err: _set_invite_fields(t, "")) + except Exception as exc: + self.after(0, lambda m=str(exc): _set_invite_fields(f"Offline ({m})", "")) + + threading.Thread(target=_load_invite_info, daemon=True).start() + + # --- Benutzer & Geräte --- + usr_sep = tk.Frame(form, bg="#B9ECFA", height=1) + usr_sep.pack(fill="x", pady=(10, 6)) + tk.Label(form, text="\U0001f465 Registrierte Benutzer", font=("Segoe UI", 10, "bold"), + bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 4)) + + _users_list_frame = tk.Frame(form, bg="#E8F4FA") + _users_list_frame.pack(fill="x") + _users_loading_lbl = tk.Label(_users_list_frame, text="Wird geladen...", + font=("Segoe UI", 9), bg="#E8F4FA", fg="#888") + _users_loading_lbl.pack(anchor="w") + + _add_row = tk.Frame(form, bg="#E8F4FA") + _add_row.pack(fill="x", pady=(6, 2)) + _add_entry = tk.Entry(_add_row, font=("Segoe UI", 9), bg="white", + fg="#1a4d6d", relief="flat") + _add_entry.pack(side="left", fill="x", expand=True, ipady=3) + _add_entry.insert(0, "Neuer Benutzername...") + _add_entry.configure(fg="#aaa") + + def _add_focus_in(e): + if _add_entry.get() == "Neuer Benutzername..." and _add_entry.cget("fg") == "#aaa": + _add_entry.delete(0, "end") + _add_entry.configure(fg="#1a4d6d") + + def _add_focus_out(e): + if not _add_entry.get().strip(): + _add_entry.delete(0, "end") + _add_entry.insert(0, "Neuer Benutzername...") + _add_entry.configure(fg="#aaa") + + _add_entry.bind("", _add_focus_in) + _add_entry.bind("", _add_focus_out) + + def _render_users(users_full): + for w in _users_list_frame.winfo_children(): + w.destroy() + if not users_full: + tk.Label(_users_list_frame, text="Keine Benutzer registriert.", + font=("Segoe UI", 9), bg="#E8F4FA", fg="#888").pack(anchor="w") + return + for u in users_full: + uid = u.get("user_id", "") + dname = u.get("display_name", uid) + role = u.get("role", "mpa") + role_labels = {"admin": "Admin", "arzt": "Arzt", + "mpa": "MPA", "empfang": "Empfang"} + role_text = role_labels.get(role, role) + devs = u.get("devices", []) + card = tk.Frame(_users_list_frame, bg="#f0f4f8", relief="solid", bd=1) + card.pack(fill="x", pady=1) + info_row = tk.Frame(card, bg="#f0f4f8") + info_row.pack(fill="x", padx=6, pady=3) + tk.Label(info_row, text=dname, font=("Segoe UI", 9, "bold"), + bg="#f0f4f8", fg="#1a4d6d").pack(side="left") + tk.Label(info_row, text=f" {role_text}", font=("Segoe UI", 8), + bg="#f0f4f8", fg="#5B8DB3").pack(side="left") + for dev in devs: + dev_name = dev.get("device_name", "") + dev_ip = dev.get("ip_last", "") + dev_info = dev_name or dev.get("platform", "") + if dev_ip: + dev_info += f" ({dev_ip})" + if dev_info: + dev_row = tk.Frame(card, bg="#f0f4f8") + dev_row.pack(fill="x", padx=6, pady=(0, 2)) + tk.Label(dev_row, text=f" \U0001f4bb {dev_info}", + font=("Segoe UI", 8), bg="#f0f4f8", fg="#888").pack(side="left") + last_active = dev.get("last_active", "") + if last_active: + tk.Label(dev_row, text=f" zuletzt: {last_active[:16]}", + font=("Segoe UI", 7), bg="#f0f4f8", + fg="#aaa").pack(side="left") + if not devs: + dev_row = tk.Frame(card, bg="#f0f4f8") + dev_row.pack(fill="x", padx=6, pady=(0, 2)) + tk.Label(dev_row, text=" \U0001f4bb Kein Geraet registriert", + font=("Segoe UI", 8), bg="#f0f4f8", fg="#aaa").pack(side="left") + + def _do_del(name=dname): + if not messagebox.askyesno( + "Benutzer entfernen", + f"'{name}' wirklich entfernen?\n\n" + "Sessions und Geraete werden geloescht.", + parent=dlg): + return + def _worker(): + try: + bu = self.get_backend_url() + r = requests.post( + f"{bu}/empfang/users", + json={"name": name, "action": "delete"}, + headers=self._empfang_headers(), timeout=5) + if r.status_code == 200: + self.after(0, _refresh_user_list) + self.after(0, lambda: self.set_status(f"Benutzer '{name}' entfernt.")) + else: + self.after(0, lambda: messagebox.showerror( + "Fehler", f"Loeschen fehlgeschlagen.", parent=dlg)) + except Exception as exc: + self.after(0, lambda m=str(exc): messagebox.showerror( + "Fehler", m, parent=dlg)) + threading.Thread(target=_worker, daemon=True).start() + + tk.Label(info_row, text="\u2715", font=("Segoe UI", 9), + bg="#f0f4f8", fg="#cc4444", cursor="hand2").pack(side="right") + info_row.winfo_children()[-1].bind("", lambda e, n=dname: _do_del(n)) + + def _refresh_user_list(): + def _fetch(): + users_full = [] + try: + bu = self.get_backend_url() + r = requests.get(f"{bu}/empfang/users", + headers=self._empfang_headers(), timeout=5) + if r.status_code == 200: + data = r.json() + users_full = data.get("users_full", []) + if not users_full: + names = data.get("users", []) + users_full = [{"display_name": n, "role": "mpa"} for n in names] + except Exception: + pass + self.after(0, lambda: _render_users(users_full)) + threading.Thread(target=_fetch, daemon=True).start() + + def _add_user(): + new_name = _add_entry.get().strip() + if not new_name or new_name == "Neuer Benutzername...": + return + def _worker(): + try: + bu = self.get_backend_url() + r = requests.post( + f"{bu}/empfang/users", + json={"name": new_name, "action": "add"}, + headers=self._empfang_headers(), timeout=5) + if r.status_code == 200: + self.after(0, lambda: _add_entry.delete(0, "end")) + self.after(0, lambda: _add_entry.insert(0, "Neuer Benutzername...")) + self.after(0, lambda: _add_entry.configure(fg="#aaa")) + self.after(0, _refresh_user_list) + self.after(0, lambda: self.set_status(f"Benutzer '{new_name}' hinzugefuegt.")) + else: + self.after(0, lambda: messagebox.showerror( + "Fehler", "Hinzufuegen fehlgeschlagen.", parent=dlg)) + except Exception as exc: + self.after(0, lambda m=str(exc): messagebox.showerror( + "Fehler", m, parent=dlg)) + threading.Thread(target=_worker, daemon=True).start() + + tk.Button(_add_row, text="+ Hinzufuegen", font=("Segoe UI", 9), + bg="#5B8DB3", fg="white", relief="flat", padx=10, pady=2, + cursor="hand2", command=_add_user).pack(side="left", padx=(6, 0)) + _add_entry.bind("", lambda e: _add_user()) + + _refresh_user_list() + + # --- Passwort ändern (Kopfzeile und ausklappbare Felder direkt darunter, vor Speichern) --- + pw_sep = tk.Frame(form, bg="#B9ECFA", height=1) + pw_sep.pack(fill="x", pady=(10, 6)) + + _pw_header = tk.Frame(form, bg="#E8F4FA", cursor="hand2") + _pw_header.pack(fill="x", pady=(2, 0)) + _pw_arrow = tk.Label(_pw_header, text="\u25B6", font=("Segoe UI", 8), + bg="#E8F4FA", fg="#5B8DB3", width=2) + _pw_arrow.pack(side="left") + tk.Label(_pw_header, text="\U0001f511 Passwort \u00e4ndern", + font=_LBL_FONT, bg="#E8F4FA", fg=_FG_COLOR, + cursor="hand2").pack(side="left", padx=(2, 0)) + + _pw_body = tk.Frame(form, bg="#E8F4FA") + + pw_old_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat", bd=0, show="") - pw_new_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR, + pw_new_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat", bd=0, show="") - pw_confirm_e = tk.Entry(form, font=_FLD_FONT, bg="white", fg=_FG_COLOR, + pw_confirm_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat", bd=0, show="") + tk.Label(_pw_body, text="Leer lassen, um das Passwort beizubehalten.", + font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(4, 0)) + if self._user_profile.get("password_hash"): - tk.Label(form, text="Altes Passwort:", font=("Segoe UI", 9), + tk.Label(_pw_body, text="Altes Passwort:", font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0)) pw_old_e.pack(fill="x", ipady=3, pady=(0, 4)) - tk.Label(form, text="Neues Passwort:", font=("Segoe UI", 9), + tk.Label(_pw_body, text="Neues Passwort:", font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0)) pw_new_e.pack(fill="x", ipady=3, pady=(0, 4)) - tk.Label(form, text="Neues Passwort bestätigen:", font=("Segoe UI", 9), + tk.Label(_pw_body, text="Neues Passwort bestaetigen:", font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0)) pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4)) + _pw_open = [False] + + def _toggle_pw(event=None): + _pw_open[0] = not _pw_open[0] + if _pw_open[0]: + _pw_body.pack(fill="x", pady=(4, 0)) + _pw_arrow.configure(text="\u25BC") + else: + _pw_body.pack_forget() + _pw_arrow.configure(text="\u25B6") + + _pw_header.bind("", _toggle_pw) + for ch in _pw_header.winfo_children(): + ch.bind("", _toggle_pw) + def do_save(): name = name_e._get_real_value() if hasattr(name_e, '_get_real_value') else name_e.get().strip() if not name: @@ -3025,7 +3432,8 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin "email": _gv(email_e), "password_hash": pw_hash, } - for k in ("totp_secret_enc", "totp_active", "backup_codes"): + for k in ("totp_secret_enc", "totp_active", "backup_codes", + "practice_id", "license_key"): if k in self._user_profile: updated[k] = self._user_profile[k] self._user_profile = updated @@ -3033,86 +3441,6 @@ class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettin self.set_status(f"Profil gespeichert: {name}") dlg.destroy() - # --- 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)) @@ -5166,6 +5494,50 @@ WICHTIG unbedingt einhalten: "geplante massnahmen", "geplante maßnahmen"), } + def _provision_empfang_account(self): + """Erstellt/aktualisiert automatisch einen Empfang-Server-Account + basierend auf dem lokalen Desktop-Profil. Laeuft einmal beim Start. + Speichert die practice_id vom Server im lokalen Profil.""" + def _do(): + try: + name = self._user_profile.get("name", "").strip() + email = self._user_profile.get("email", "").strip() + clinic = self._user_profile.get("clinic", "").strip() + if not name or name == "Benutzer": + return + bu = self.get_backend_url() + pw = email.split("@")[0] if email else name.lower() + if len(pw) < 4: + pw = pw + "1234" + payload = {"name": name, "email": email, "password": pw} + if clinic: + payload["practice_name"] = clinic + existing_pid = self.get_practice_id() + if existing_pid: + payload["practice_id"] = existing_pid + r = requests.post( + f"{bu}/empfang/auth/provision", + json=payload, + headers=self._empfang_headers(), + timeout=8, + ) + if r.status_code == 200: + d = r.json() + action = d.get("action", "") + role = d.get("role", "") + server_pid = d.get("practice_id", "") + if server_pid and server_pid != self.get_practice_id(): + self._user_profile["practice_id"] = server_pid + save_user_profile(self._user_profile) + print(f"[PROVISION] practice_id gespeichert: {server_pid}") + if action == "created": + print(f"[PROVISION] Empfang-Account erstellt: {name} ({role})") + elif action == "updated": + print(f"[PROVISION] Empfang-Account aktualisiert: {name} ({role})") + except Exception as exc: + print(f"[PROVISION] Empfang-Provisioning uebersprungen: {exc}") + threading.Thread(target=_do, daemon=True).start() + def _extract_kg_sections(self) -> dict: """Extrahiert Medikamente, Therapieplan und Procedere aus der KG.""" kg_text = self.txt_output.get("1.0", "end").strip() @@ -5205,9 +5577,8 @@ WICHTIG unbedingt einhalten: def _check(): try: bu = self.get_backend_url() - bt = self.get_backend_token() r = requests.get(f"{bu}/empfang/messages", - headers={"X-API-Token": bt}, timeout=8) + headers=self._empfang_headers(), timeout=8) if r.status_code == 200: msgs = r.json().get("messages", []) current_ids = {m["id"] for m in msgs if m.get("status") == "offen"} @@ -5582,6 +5953,10 @@ WICHTIG unbedingt einhalten: else: btn.configure(text="\U0001f50d Lupe", bg="#dde8f0", fg="#1a4d6d") + # --- Feste Knopfleiste am unteren Rand --- + bottom_bar = tk.Frame(outer, bg="#E8F4FA") + bottom_bar.pack(side="bottom", fill="x", padx=14, pady=(4, 8)) + # --- Scrollbarer Inhalt --- canvas = tk.Canvas(outer, bg="#E8F4FA", highlightthickness=0, bd=0) vsb = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview) @@ -5890,7 +6265,7 @@ WICHTIG unbedingt einhalten: reply_entry.bind("", _reply_focus_out) reply_btn_bar = tk.Frame(inner, bg="#E8F4FA") - reply_btn_bar.pack(fill="x", pady=(0, 6)) + reply_btn_bar.pack(fill="x", pady=(0, 2)) def _update_chat(messages): chat_display.configure(state="normal") @@ -5923,6 +6298,7 @@ WICHTIG unbedingt einhalten: try: backend_url = self.get_backend_url() backend_token = self.get_backend_token() + _empfang_hdrs = self._empfang_headers() except Exception: return @@ -5930,7 +6306,7 @@ WICHTIG unbedingt einhalten: try: r = requests.get( f"{backend_url}/empfang/thread/{_active_thread[0]}", - headers={"X-API-Token": backend_token}, + headers=_empfang_hdrs, timeout=8, ) if r.status_code == 200: @@ -5970,9 +6346,8 @@ WICHTIG unbedingt einhalten: def _refresh_recipients(): try: bu = self.get_backend_url() - bt = self.get_backend_token() r = requests.get(f"{bu}/empfang/users", - headers={"X-API-Token": bt}, timeout=5) + headers=self._empfang_headers(), timeout=5) if r.status_code == 200: names = r.json().get("users", []) self.after(0, lambda: _rcpt_combo.configure( @@ -5995,6 +6370,7 @@ WICHTIG unbedingt einhalten: try: backend_url = self.get_backend_url() backend_token = self.get_backend_token() + _send_hdrs = self._empfang_headers() except Exception as e: messagebox.showerror("Fehler", f"Backend nicht konfiguriert:\n{e}", parent=dlg) return @@ -6043,7 +6419,7 @@ WICHTIG unbedingt einhalten: r = requests.post( f"{backend_url}/empfang/send", json=payload, - headers={"X-API-Token": backend_token}, + headers=_send_hdrs, timeout=(5, 15), ) if r.status_code == 404: @@ -6109,7 +6485,7 @@ WICHTIG unbedingt einhalten: return try: backend_url = self.get_backend_url() - backend_token = self.get_backend_token() + _reply_hdrs = self._empfang_headers() except Exception: return reply_payload = { @@ -6125,7 +6501,7 @@ WICHTIG unbedingt einhalten: r = requests.post( f"{backend_url}/empfang/send", json=reply_payload, - headers={"X-API-Token": backend_token}, + headers=_reply_hdrs, timeout=(5, 15), ) r.raise_for_status() @@ -6227,9 +6603,8 @@ WICHTIG unbedingt einhalten: def _cv_fetch(): try: bu = self.get_backend_url() - bt = self.get_backend_token() r = requests.get(f"{bu}/empfang/messages", - headers={"X-API-Token": bt}, timeout=10) + headers=self._empfang_headers(), timeout=10) if r.status_code == 200: msgs = r.json().get("messages", []) threads = {} @@ -6308,7 +6683,7 @@ WICHTIG unbedingt einhalten: return try: backend_url = self.get_backend_url() - backend_token = self.get_backend_token() + _task_hdrs = self._empfang_headers() patient_val = _get_text("patient") if toggle_vars["patient"].get() else "" if not patient_val: if not hasattr(self, '_empfang_chat_nr'): @@ -6331,7 +6706,7 @@ WICHTIG unbedingt einhalten: try: r = requests.post( f"{backend_url}/empfang/send", json=payload, - headers={"X-API-Token": backend_token}, timeout=(5, 15)) + headers=_task_hdrs, timeout=(5, 15)) r.raise_for_status() self.after(0, lambda: _flash_bg(True)) self.after(0, lambda: self.set_status("Aufgabe gesendet.")) @@ -6344,53 +6719,47 @@ WICHTIG unbedingt einhalten: except Exception as e: messagebox.showerror("Fehler", str(e), parent=dlg) - # --- Aktionsleiste (einheitlich, vor dem Chat-Bereich) --- + # --- Aktionsleiste (einheitlich, fester unterer Rand) --- _BTN_F = ("Segoe UI", 9) _BTN_BG = "#e8eef4" _BTN_FG = "#1a4d6d" _BTN_PAD = dict(padx=12, pady=4, relief="flat", cursor="hand2", bd=0) - btn_bar = tk.Frame(inner, bg="#E8F4FA") - btn_bar.pack(fill="x", pady=(10, 4), before=chat_sep) + tk.Button(bottom_bar, text="Senden", font=_BTN_F, + bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", + **_BTN_PAD, command=do_send).pack(side="left", padx=(0, 4)) - tk.Button(btn_bar, text="Senden", font=(_BTN_F[0], 9, "bold"), - bg="#5B8DB3", fg="white", activebackground="#4A7A9E", - **_BTN_PAD).configure(command=do_send) - btn_bar.winfo_children()[-1].pack(side="left", padx=(0, 4)) - - _dik_btn_kom = tk.Button(btn_bar, text="\u23fa Diktieren", + _dik_btn_kom = tk.Button(bottom_bar, text="\u23fa Diktieren", font=_BTN_F, bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", **_BTN_PAD) _dik_btn_kom.configure( command=lambda b=_dik_btn_kom: _toggle_dik(b, field_widgets.get("kom"))) _dik_btn_kom.pack(side="left", padx=(0, 4)) - tk.Button(btn_bar, text="\u2611 Aufgabe", font=_BTN_F, + tk.Button(bottom_bar, text="\u2611 Aufgabe", font=_BTN_F, bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", **_BTN_PAD, command=_send_task).pack(side="left", padx=(0, 4)) - tk.Button(btn_bar, text="Verlauf", font=_BTN_F, + tk.Button(bottom_bar, text="Verlauf", font=_BTN_F, bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", **_BTN_PAD, command=_open_chat_verlauf).pack(side="left", padx=(0, 4)) - tk.Button(btn_bar, text="Schliessen", font=_BTN_F, - bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", - **_BTN_PAD, command=_on_close).pack(side="right") - - # --- Antwortfeld: nur Lupe --- def _start_pick_reply(): _do_smart_pick(_reply_pick_btn, lambda t: reply_entry.insert("end", t)) - _reply_pick_btn = tk.Button(reply_btn_bar, text="\U0001f50d Lupe", - font=("Segoe UI", 8), bg="#dde8f0", - fg="#1a4d6d", relief="flat", - cursor="hand2", bd=0, padx=6, pady=1, + _reply_pick_btn = tk.Button(bottom_bar, text="\U0001f50d Lupe", + font=_BTN_F, bg=_BTN_BG, fg=_BTN_FG, + activebackground="#dde8f0", **_BTN_PAD, command=_start_pick_reply) - _reply_pick_btn.pack(side="left") + _reply_pick_btn.pack(side="left", padx=(0, 4)) add_tooltip(_reply_pick_btn, "Text in anderer App markieren - wird automatisch uebernommen") + tk.Button(bottom_bar, text="Schliessen", font=_BTN_F, + bg=_BTN_BG, fg=_BTN_FG, activebackground="#dde8f0", + **_BTN_PAD, command=_on_close).pack(side="right") + def _diktat_into_widget(self, parent_win, text_widget, status_callback=None): """Öffnet kleines Aufnahme-Fenster, transkribiert und fügt Text an Cursorposition in text_widget ein.""" if not self.ensure_ready(): @@ -6978,6 +7347,18 @@ WICHTIG unbedingt einhalten: return token raise RuntimeError("Backend-Token fehlt: backend_token.txt oder MEDWORK_API_TOKEN setzen.") + def get_practice_id(self): + """Gibt die gespeicherte practice_id zurueck (oder leer).""" + return (self._user_profile.get("practice_id") or "").strip() + + def _empfang_headers(self) -> dict: + """Standard-Headers fuer alle Empfang-Requests: API-Token + Practice-Id.""" + h = {"X-API-Token": self.get_backend_token()} + pid = self.get_practice_id() + if pid: + h["X-Practice-Id"] = pid + return h + def _open_billing_portal_from_ui(self): try: backend_url = self.get_backend_url() diff --git a/AzA march 2026/empfang_app_settings.json b/AzA march 2026/empfang_app_settings.json new file mode 100644 index 0000000..d78c0bd --- /dev/null +++ b/AzA march 2026/empfang_app_settings.json @@ -0,0 +1,7 @@ +{ + "x": 1181, + "y": 224, + "width": 480, + "height": 820, + "on_top": false +} \ No newline at end of file diff --git a/AzA march 2026/empfang_routes.py b/AzA march 2026/empfang_routes.py index ba4b0c4..d5aae8a 100644 --- a/AzA march 2026/empfang_routes.py +++ b/AzA march 2026/empfang_routes.py @@ -15,7 +15,7 @@ import secrets import time import uuid from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response from fastapi.responses import HTMLResponse, JSONResponse @@ -33,10 +33,39 @@ _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" +_LEGACY_DEFAULT_PID = "default" SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage +def _generate_practice_id() -> str: + return f"prac_{uuid.uuid4().hex[:12]}" + + +def _resolve_practice_id(request: Request) -> str: + """Ermittelt die practice_id aus Session, Header oder Query. + Kein stiller Fallback auf eine Default-Praxis.""" + s = _session_from_request(request) + if s: + return s["practice_id"] + pid = request.headers.get("X-Practice-Id", "").strip() + if pid: + return pid + pid = request.query_params.get("practice_id", "").strip() + if pid: + return pid + return "" + + +def _require_practice_id(request: Request) -> str: + """Wie _resolve_practice_id, aber wirft 400 wenn keine practice_id.""" + pid = _resolve_practice_id(request) + if not pid: + raise HTTPException( + status_code=400, + detail="practice_id erforderlich (X-Practice-Id Header, Session oder Query)") + return pid + + def _ensure_data_dir(): _DATA_DIR.mkdir(parents=True, exist_ok=True) @@ -90,18 +119,79 @@ def _save_practices(data: dict): _save_json(_PRACTICES_FILE, data) -def _ensure_default_practice(): +def _generate_chat_invite_code() -> str: + """Lesbarer Chat-Einladungscode im Format CHAT-XXXX-XXXX.""" + import random + chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + part1 = "".join(random.choices(chars, k=4)) + part2 = "".join(random.choices(chars, k=4)) + return f"CHAT-{part1}-{part2}" + + +def _ensure_practice(practice_id: str, name: str = "Meine Praxis", + admin_email: str = "") -> dict: + """Stellt sicher, dass eine Praxis mit dieser ID existiert.""" practices = _load_practices() - if DEFAULT_PRACTICE_ID not in practices: - practices[DEFAULT_PRACTICE_ID] = { - "practice_id": DEFAULT_PRACTICE_ID, - "name": "Meine Praxis", - "invite_code": secrets.token_urlsafe(8), + if practice_id not in practices: + practices[practice_id] = { + "practice_id": practice_id, + "name": name, + "invite_code": _generate_chat_invite_code(), + "admin_email": admin_email, "created": time.strftime("%Y-%m-%d %H:%M:%S"), } _save_practices(practices) - _migrate_old_users(DEFAULT_PRACTICE_ID) - return practices[DEFAULT_PRACTICE_ID] + return practices[practice_id] + + +def _migrate_legacy_to_practice(new_pid: str): + """Migriert alle Daten von der alten 'default'-Praxis zur neuen practice_id. + Wird einmalig beim ersten Provisioning aufgerufen.""" + accounts = _load_accounts() + migrated = False + for a in accounts.values(): + if a.get("practice_id") == _LEGACY_DEFAULT_PID: + a["practice_id"] = new_pid + migrated = True + if migrated: + _save_accounts(accounts) + + devices = _load_devices() + for d in devices.values(): + if d.get("practice_id") == _LEGACY_DEFAULT_PID: + d["practice_id"] = new_pid + _save_devices(devices) + + sessions = _load_sessions() + for s in sessions.values(): + if s.get("practice_id") == _LEGACY_DEFAULT_PID: + s["practice_id"] = new_pid + _save_sessions(sessions) + + messages = _load_messages() + for m in messages: + if m.get("practice_id", _LEGACY_DEFAULT_PID) == _LEGACY_DEFAULT_PID: + m["practice_id"] = new_pid + _save_messages(messages) + + practices = _load_practices() + if _LEGACY_DEFAULT_PID in practices: + old = practices.pop(_LEGACY_DEFAULT_PID) + if new_pid not in practices: + old["practice_id"] = new_pid + practices[new_pid] = old + _save_practices(practices) + + try: + channels = _load_channels() + for c in channels: + if c.get("practice_id") == _LEGACY_DEFAULT_PID: + c["practice_id"] = new_pid + _save_channels(channels) + except Exception: + pass + + _migrate_old_users(new_pid) def _migrate_old_users(practice_id: str): @@ -163,6 +253,82 @@ def _practice_users(practice_id: str) -> list[dict]: ] +# ===================================================================== +# Login / Passwort-Reset Hilfen (Mandant, Mehrfach-E-Mail) +# ===================================================================== + + +def _is_likely_email(s: str) -> bool: + """Grobe Erkennung E-Mail vs. Benutzername (Anzeigename).""" + s = (s or "").strip() + if "@" not in s or len(s) < 5: + return False + parts = s.split("@", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + return False + return "." in parts[1] + + +def _norm_email(e: str) -> str: + return (e or "").strip().lower() + + +def _practice_id_from_client(request: Request, body: dict) -> str: + """practice_id fuer Login/Forgot ohne Session: Body, Header, Query.""" + pid = (body.get("practice_id") or "").strip() + if pid: + return pid + pid = request.headers.get("X-Practice-Id", "").strip() + if pid: + return pid + return request.query_params.get("practice_id", "").strip() + + +def _practice_label(practices: dict, pid: str) -> str: + p = practices.get(pid) or {} + return (p.get("name") or "").strip() or pid + + +def _send_reset_for_account(acc: dict) -> dict: + """Token erstellen, Mail senden — Inhalt fuer JSONResponse(content=...).""" + email_to = (acc.get("email") or "").strip() + if not email_to: + return { + "success": False, + "step": "no_email", + "message": ( + "Für dieses Konto ist keine E-Mail-Adresse hinterlegt. Bitte lassen Sie das " + "Passwort von einer Administratorin oder einem Administrator der Praxis " + "zurücksetzen." + ), + } + reset_token = secrets.token_urlsafe(32) + resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) + resets[reset_token] = { + "user_id": acc["user_id"], + "email": _norm_email(email_to), + "display_name": (acc.get("display_name") or "").strip(), + "practice_id": (acc.get("practice_id") or "").strip(), + "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) + _web_base = os.environ.get( + "EMPFANG_WEB_BASE", "https://empfang.aza-medwork.ch/empfang" + ).rstrip("/") + reset_link = f"{_web_base}/?reset_token={reset_token}" + _send_reset_email(email_to, acc.get("display_name", ""), reset_link) + return { + "success": True, + "step": "sent", + "message": ( + "Ein Link zum Zurücksetzen wurde an die hinterlegte E-Mail-Adresse gesendet." + ), + } + + # ===================================================================== # Sessions (mit device_id) # ===================================================================== @@ -365,7 +531,7 @@ def _save_messages(messages: list[dict]): def _msg_practice(m: dict) -> str: - return m.get("practice_id") or DEFAULT_PRACTICE_ID + return m.get("practice_id") or _LEGACY_DEFAULT_PID def _filter_by_practice(messages: list[dict], pid: str) -> list[dict]: @@ -443,39 +609,41 @@ def _save_connections(conns: list[dict]): @router.post("/auth/setup") async def auth_setup(request: Request): - """Erstellt den ersten Admin-Benutzer fuer die Default-Praxis. - Nur aufrufbar wenn noch keine Accounts existieren.""" - practice = _ensure_default_practice() - accounts = _load_accounts() - practice_accounts = [a for a in accounts.values() - if a.get("practice_id") == DEFAULT_PRACTICE_ID] - if practice_accounts: - raise HTTPException(status_code=409, - detail="Setup bereits abgeschlossen. Bitte Login verwenden.") + """Erstellt den ersten Admin-Benutzer und eine neue Praxis.""" try: body = await request.json() except Exception: body = {} name = (body.get("name") or "").strip() password = (body.get("password") or "").strip() - practice_name = (body.get("practice_name") or "").strip() + practice_name = (body.get("practice_name") or "").strip() or "Meine Praxis" admin_email = (body.get("email") or "").strip() + pid = (body.get("practice_id") 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 not pid: + pid = _generate_practice_id() + practice = _ensure_practice(pid, name=practice_name, admin_email=admin_email) + accounts = _load_accounts() + practice_accounts = [a for a in accounts.values() + if a.get("practice_id") == pid] + if practice_accounts: + raise HTTPException(status_code=409, + detail="Setup bereits abgeschlossen. Bitte Login verwenden.") if practice_name: practices = _load_practices() - practices[DEFAULT_PRACTICE_ID]["name"] = practice_name + practices[pid]["name"] = practice_name if admin_email: - practices[DEFAULT_PRACTICE_ID]["admin_email"] = admin_email + practices[pid]["admin_email"] = admin_email _save_practices(practices) - practice = practices[DEFAULT_PRACTICE_ID] + practice = practices[pid] 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, + "practice_id": pid, "display_name": name, "email": admin_email, "role": "admin", @@ -487,16 +655,16 @@ async def auth_setup(request: Request): } _save_accounts(accounts) - _ensure_default_channels(DEFAULT_PRACTICE_ID) + _ensure_default_channels(pid) 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", + token = _create_session(uid, pid, 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, + "display_name": name, "practice_id": pid, "practice_name": practice.get("name", ""), "invite_code": practice.get("invite_code", ""), }) @@ -507,56 +675,128 @@ async def auth_setup(request: Request): @router.post("/auth/login") async def auth_login(request: Request): - """Login mit Name oder E-Mail + Passwort.""" + """Login mit Benutzername (Anzeigename) oder E-Mail + Passwort, mandantenbewusst.""" try: body = await request.json() except Exception: body = {} - name = (body.get("name") or "").strip() + raw = (body.get("name") or "").strip() 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/E-Mail und Passwort erforderlich") + pid = _practice_id_from_client(request, body) + if not pid: + pid = _resolve_practice_id(request) + if not raw or not password: + raise HTTPException( + status_code=400, + detail="Benutzername oder E-Mail und Passwort erforderlich", + ) accounts = _load_accounts() - name_lower = name.lower() + scoped = ( + [a for a in accounts.values() if a.get("practice_id") == pid] + if pid + else list(accounts.values()) + ) target = None - for a in accounts.values(): - 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 _is_likely_email(raw): + em = _norm_email(raw) + matches = [ + a + for a in scoped + if em and _norm_email(a.get("email") or "") == em + ] + if len(matches) == 1: + target = matches[0] + elif len(matches) == 0: + target = None + else: + if pid: + raise HTTPException( + status_code=409, + detail={ + "code": "ambiguous_email", + "message": ( + "Mehrere Benutzer mit dieser E-Mail in dieser Praxis. " + "Bitte melden Sie sich mit Ihrem Benutzernamen an." + ), + "candidates": [ + {"display_name": (a.get("display_name") or "")} + for a in matches + ], + }, + ) + raise HTTPException( + status_code=409, + detail={ + "code": "ambiguous_email", + "message": ( + "Diese E-Mail ist mehreren Konten zugeordnet. " + "Bitte melden Sie sich mit Ihrem Benutzernamen an." + ), + }, + ) + else: + matches = [a for a in scoped if (a.get("display_name") or "") == raw] + if len(matches) == 1: + target = matches[0] + elif len(matches) == 0: + target = None + else: + raise HTTPException( + status_code=409, + detail={ + "code": "ambiguous_username", + "message": ( + "Dieser Benutzername ist mehrdeutig. Bitte wenden Sie sich an Ihre Praxis." + ), + }, + ) + if not target: - raise HTTPException(status_code=401, detail="Benutzer nicht gefunden") + raise HTTPException( + status_code=401, + detail="Benutzer nicht gefunden oder falsches Passwort", + ) + if not pid: + pid = target.get("practice_id", "") if target.get("status") == "deactivated": - raise HTTPException(status_code=403, - detail="Konto deaktiviert. Bitte Administrator kontaktieren.") + 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") + raise HTTPException( + status_code=401, + detail="Benutzer nicht gefunden oder falsches Passwort", + ) now = time.strftime("%Y-%m-%d %H:%M:%S") target["last_login"] = now _save_accounts(accounts) + dn = (target.get("display_name") or raw).strip() 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) + token = _create_session( + target["user_id"], pid, dn, 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, + "success": True, + "user_id": target["user_id"], + "role": target["role"], + "display_name": dn, + "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) + resp.set_cookie( + "aza_session", token, httponly=True, samesite="lax", + max_age=SESSION_MAX_AGE, + ) return resp @@ -649,14 +889,23 @@ async def auth_logout(request: Request): @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") + """Erzeugt einen neuen Chat-Einladungscode (Admin-Session oder API-Token).""" + api_token = request.headers.get("X-API-Token", "") + s = _session_from_request(request) + if s: + if s.get("role") != "admin": + raise HTTPException(status_code=403, detail="Nur Admin darf Einladungscode erneuern") + pid = s["practice_id"] + elif api_token: + pid = request.headers.get("X-Practice-Id", "").strip() + if not pid: + raise HTTPException(status_code=400, detail="X-Practice-Id Header erforderlich") + else: + raise HTTPException(status_code=401, detail="Nicht authentifiziert") + _ensure_practice(pid) practices = _load_practices() - pid = s["practice_id"] if pid in practices: - practices[pid]["invite_code"] = secrets.token_urlsafe(8) + practices[pid]["invite_code"] = _generate_chat_invite_code() _save_practices(practices) return JSONResponse(content={ "success": True, @@ -664,45 +913,256 @@ async def auth_regenerate_invite(request: Request): }) -@router.post("/auth/forgot_password") -async def auth_forgot_password(request: Request): - """Sendet einen Passwort-Reset-Link per E-Mail.""" +@router.post("/auth/provision") +async def auth_provision(request: Request): + """Provisioning: Desktop-App erstellt/findet Server-Account. + Authentifiziert via X-API-Token (Backend-Token), nicht via Session. + Erstellt bei Bedarf eine neue Praxis mit echter practice_id.""" + api_token = request.headers.get("X-API-Token", "") + if not api_token: + raise HTTPException(status_code=401, detail="API-Token erforderlich") 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") + name = (body.get("name") or "").strip() + email = (body.get("email") or "").strip() + password = (body.get("password") or "").strip() + practice_name = (body.get("practice_name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Name erforderlich") + if not password or len(password) < 4: + raise HTTPException(status_code=400, detail="Passwort (min. 4 Zeichen) erforderlich") + + pid = request.headers.get("X-Practice-Id", "").strip() + pid = pid or (body.get("practice_id") or "").strip() + + if not pid and email: + try: + from stripe_routes import lookup_practice_id_for_license_email + + lp = lookup_practice_id_for_license_email(email) + if lp: + pid = lp.strip() + except Exception as exc: + print(f"[EMPFANG] lookup_practice_id_for_license_email: {exc}") + + if not pid: + practices = _load_practices() + has_legacy = _LEGACY_DEFAULT_PID in practices + accounts = _load_accounts() + has_legacy_accounts = any( + a.get("practice_id") == _LEGACY_DEFAULT_PID for a in accounts.values()) + if has_legacy or has_legacy_accounts: + pid = _generate_practice_id() + _migrate_legacy_to_practice(pid) + else: + pid = _generate_practice_id() + + practice = _ensure_practice(pid, name=practice_name or "Meine Praxis", + admin_email=email) + if practice_name: + practices = _load_practices() + practices[pid]["name"] = practice_name + if email: + practices[pid]["admin_email"] = email + _save_practices(practices) + accounts = _load_accounts() target = None + email_lower = email.lower() if email else "" for a in accounts.values(): - if (a.get("email") or "").strip().lower() == email: + if a.get("practice_id") != pid: + continue + if email_lower and (a.get("email") or "").strip().lower() == email_lower: 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"], + if a["display_name"] == name: + target = a + break + + if target: + pw_hash, pw_salt = _hash_password(password) + target["pw_hash"] = pw_hash + target["pw_salt"] = pw_salt + if email: + target["email"] = email + target["display_name"] = name + if not target.get("role") or target.get("role") == "mpa": + has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid + for a in accounts.values()) + if not has_admin: + target["role"] = "admin" + _save_accounts(accounts) + return JSONResponse(content={ + "success": True, "user_id": target["user_id"], + "display_name": target["display_name"], "role": target["role"], + "practice_id": pid, + "action": "updated", + }) + + has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid + for a in accounts.values()) + role = "admin" if not has_admin else "arzt" + uid = uuid.uuid4().hex[:12] + pw_hash, pw_salt = _hash_password(password) + accounts[uid] = { + "user_id": uid, + "practice_id": pid, + "display_name": name, "email": email, - "created": time.time(), + "role": role, + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "status": "active", + "created": time.strftime("%Y-%m-%d %H:%M:%S"), } - 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."}) + _save_accounts(accounts) + return JSONResponse(content={ + "success": True, "user_id": uid, + "display_name": name, "role": role, + "practice_id": pid, + "action": "created", + }) + + +@router.post("/auth/forgot_password") +async def auth_forgot_password(request: Request): + """Passwort-Reset: Benutzername oder E-Mail; bei mehreren Konten mit gleicher E-Mail zweiter Schritt.""" + try: + body = await request.json() + except Exception: + body = {} + raw = (body.get("login") or body.get("email") or body.get("name") or "").strip() + chosen_display_name = (body.get("display_name") or body.get("username") or "").strip() + pid = _practice_id_from_client(request, body) + if not raw: + raise HTTPException( + status_code=400, detail="Benutzername oder E-Mail erforderlich", + ) + accounts = _load_accounts() + practices = _load_practices() + _neutral = { + "success": True, + "step": "sent", + "message": ( + "Wenn ein passendes Konto existiert, wurde ein Link an die hinterlegte " + "E-Mail-Adresse gesendet." + ), + } + + # Zweiter Schritt: E-Mail + gewählter Benutzername (eindeutig) + if chosen_display_name and _is_likely_email(raw): + if not pid: + raise HTTPException( + status_code=400, + detail=( + "Praxis-Kontext fehlt. Bitte laden Sie die Seite neu oder " + "kontaktieren Sie Ihre Praxis." + ), + ) + em = _norm_email(raw) + scoped = [a for a in accounts.values() if a.get("practice_id") == pid] + picked = [ + a + for a in scoped + if _norm_email(a.get("email") or "") == em + and (a.get("display_name") or "") == chosen_display_name + ] + if len(picked) == 1: + return JSONResponse(content=_send_reset_for_account(picked[0])) + return JSONResponse(content=_neutral) + + if _is_likely_email(raw): + em = _norm_email(raw) + if pid: + scoped = [a for a in accounts.values() if a.get("practice_id") == pid] + matches = [a for a in scoped if _norm_email(a.get("email") or "") == em] + else: + matches = [ + a for a in accounts.values() + if _norm_email(a.get("email") or "") == em + ] + if len(matches) == 0: + return JSONResponse(content=_neutral) + if len(matches) == 1: + return JSONResponse(content=_send_reset_for_account(matches[0])) + cands = [] + for a in matches: + pida = a.get("practice_id", "") + cands.append({ + "display_name": a.get("display_name", ""), + "practice_id": pida, + "practice_name": _practice_label(practices, pida), + }) + return JSONResponse( + content={ + "success": True, + "step": "pick_user", + "login": raw, + "candidates": cands, + }, + ) + + if pid: + scoped = [a for a in accounts.values() if a.get("practice_id") == pid] + matches = [a for a in scoped if (a.get("display_name") or "") == raw] + else: + matches = [ + a for a in accounts.values() + if (a.get("display_name") or "") == raw + ] + if len(matches) == 0: + return JSONResponse(content=_neutral) + if len(matches) > 1: + return JSONResponse( + content={ + "success": False, + "step": "ambiguous_practice", + "message": ( + "Dieser Benutzername ist in mehreren Praxen registriert. Bitte " + "setzen Sie das Passwort über Ihre E-Mail-Adresse zurück oder " + "wenden Sie sich an Ihre Praxis." + ), + }, + ) + return JSONResponse(content=_send_reset_for_account(matches[0])) + + +@router.get("/auth/reset_verify") +async def auth_reset_verify(reset_token: str = Query("")): + """Prüft, ob ein Reset-Token noch gültig ist (ohne Verbrauch).""" + token = (reset_token or "").strip() + if not token: + return JSONResponse( + content={"valid": False, "detail": "Kein Reset-Token angegeben."} + ) + resets = _load_json(_DATA_DIR / "empfang_resets.json", {}) + entry = resets.get(token) + if not entry: + return JSONResponse( + content={"valid": False, "detail": "Ungültiger oder abgelaufener Link."} + ) + if time.time() - entry.get("created", 0) > 3600: + return JSONResponse( + content={ + "valid": False, + "detail": "Der Link ist abgelaufen (max. 1 Stunde).", + } + ) + email = (entry.get("email") or "").strip() + accounts = _load_accounts() + uid = entry.get("user_id") + acc = accounts.get(uid) if uid else None + display_name = (entry.get("display_name") or (acc or {}).get("display_name") or "").strip() + return JSONResponse( + content={"valid": True, "email": email, "display_name": display_name} + ) @router.post("/auth/reset_password") async def auth_reset_password(request: Request): - """Setzt das Passwort mit einem gueltigen Reset-Token.""" + """Setzt das Passwort mit einem gültigen Reset-Token.""" try: body = await request.json() except Exception: @@ -715,11 +1175,15 @@ async def auth_reset_password(request: Request): 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") + raise HTTPException( + status_code=400, detail="Ungültiger 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)") + 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: @@ -731,81 +1195,154 @@ async def auth_reset_password(request: Request): _save_accounts(accounts) del resets[token] _save_json(_DATA_DIR / "empfang_resets.json", resets) - return JSONResponse(content={"success": True, "message": "Passwort wurde erfolgreich geaendert."}) + acc = accounts[user_id] + email_hint = (acc.get("email") or "").strip() + dn_hint = (acc.get("display_name") or "").strip() + return JSONResponse( + content={ + "success": True, + "message": "Passwort wurde erfolgreich geändert.", + "email": email_hint, + "display_name": dn_hint, + } + ) + + +def _reset_email_subject_body(display_name: str, reset_link: str) -> Tuple[str, str, str]: + """Betreff, Plain-Text, HTML für Passwort-Reset.""" + subject = "AZA Praxis-Chat – Passwort zurücksetzen" + text = ( + f"Hallo {display_name},\n\n" + f"Sie haben eine Passwort-Zurücksetzung 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 gültig.\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 zurücksetzen

" + f"

Hallo {display_name},

" + f"

Sie haben eine Passwort-Zurücksetzung angefordert.

" + f"

Neues Passwort speichern

" + f"

Der Link ist 1 Stunde gültig.

" + f"

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

" + ) + return subject, text, html + + +def _send_reset_via_resend(to_email: str, subject: str, text: str, html: str) -> bool: + """Resend HTTP API (gleiche Umgebung wie Lizenz-Mail in stripe_routes).""" + import json + import urllib.error + import urllib.request + + api_key = os.environ.get("RESEND_API_KEY", "").strip() + sender = os.environ.get("MAIL_FROM", "AZA MedWork ").strip() + if not api_key: + return False + payload = json.dumps({ + "from": sender, + "to": [to_email], + "subject": subject, + "html": html, + "text": text, + }).encode("utf-8") + req = urllib.request.Request( + "https://api.resend.com/emails", + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "User-Agent": "AZA-MedWork/1.0", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + print(f"[RESET-MAIL] Resend OK -> {to_email}") + return True + body = resp.read().decode()[:300] + print(f"[RESET-MAIL] Resend HTTP {resp.status}: {body}") + return False + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:300] if exc.fp else "" + print(f"[RESET-MAIL] Resend HTTP {exc.code}: {body}") + return False + except Exception as exc: + print(f"[RESET-MAIL] Resend {type(exc).__name__}: {exc}") + return False def _send_reset_email(to_email: str, display_name: str, reset_link: str): - """Sendet Passwort-Reset-E-Mail via SMTP.""" + """Sendet Passwort-Reset: zuerst SMTP (falls vollstaendig), sonst Resend API.""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart + subject, text, html = _reset_email_subject_body(display_name, reset_link) + 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}") + if all([host, user, password]): + 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] SMTP OK -> {to_email}") + return + except Exception as exc: + print(f"[RESET-MAIL] SMTP FEHLER: {exc} – versuche Resend …") + + if _send_reset_via_resend(to_email, subject, text, html): 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" + print( + "[RESET-MAIL] Weder SMTP noch Resend erfolgreich. " + "Setzen Sie SMTP_HOST/SMTP_USER/SMTP_PASS oder RESEND_API_KEY (+ MAIL_FROM). " + f"Reset-Link (nur Server-Log): {reset_link}" ) - 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(): +async def auth_needs_setup(request: Request): """Pruefen ob Setup noetig ist (keine Accounts vorhanden).""" - _ensure_default_practice() + pid = _resolve_practice_id(request) + if not pid: + accounts = _load_accounts() + return JSONResponse(content={ + "needs_setup": len(accounts) == 0, + "invite_code": "", + }) + _ensure_practice(pid) accounts = _load_accounts() - has_accounts = any(a.get("practice_id") == DEFAULT_PRACTICE_ID - for a in accounts.values()) + has_accounts = any(a.get("practice_id") == pid for a in accounts.values()) practices = _load_practices() - invite_code = practices.get(DEFAULT_PRACTICE_ID, {}).get("invite_code", "") + invite_code = practices.get(pid, {}).get("invite_code", "") return JSONResponse(content={ "needs_setup": not has_accounts, "invite_code": invite_code if not has_accounts else "", @@ -1037,13 +1574,33 @@ async def empfang_users(request: Request): "users_full": users, "practice_id": s["practice_id"], }) - pid = request.query_params.get("practice_id", "") or DEFAULT_PRACTICE_ID + api_token = request.headers.get("X-API-Token", "") + pid = _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"users": [], "practice_id": ""}) users = _practice_users(pid) if users: - return JSONResponse(content={ + result: dict = { "users": [u["display_name"] for u in users], "practice_id": pid, - }) + } + if api_token: + devices = _load_devices() + user_devices: dict[str, list] = {} + for d in devices.values(): + if d.get("practice_id") != pid: + continue + uid = d.get("user_id", "") + user_devices.setdefault(uid, []).append({ + "device_name": d.get("device_name", ""), + "platform": d.get("platform", ""), + "last_active": d.get("last_active", ""), + "ip_last": d.get("ip_last", ""), + }) + for u in users: + u["devices"] = user_devices.get(u.get("user_id", ""), []) + result["users_full"] = users + return JSONResponse(content=result) old_file = _DATA_DIR / "empfang_users.json" if old_file.is_file(): try: @@ -1064,11 +1621,8 @@ async def empfang_register_user(request: Request): body = {} name = (body.get("name") or "").strip() action = (body.get("action") or "add").strip() - pid = DEFAULT_PRACTICE_ID - s = _session_from_request(request) - if s: - pid = s["practice_id"] - if not name: + pid = _resolve_practice_id(request) + if not pid or not name: return JSONResponse(content={"success": False}) accounts = _load_accounts() if action == "delete": @@ -1130,7 +1684,9 @@ class EmpfangMessage(BaseModel): @router.post("/send") async def empfang_send(msg: EmpfangMessage, request: Request): s = _session_from_request(request) - pid = msg.practice_id.strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) + pid = msg.practice_id.strip() or _resolve_practice_id(request) + if not pid: + raise HTTPException(status_code=400, detail="practice_id erforderlich") absender = msg.absender.strip() if s and not absender: absender = s["display_name"] @@ -1177,8 +1733,9 @@ async def empfang_send(msg: EmpfangMessage, request: Request): @router.get("/messages") async def empfang_list(request: Request, practice_id: Optional[str] = Query(None)): - s = _session_from_request(request) - pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) + pid = (practice_id or "").strip() or _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"success": True, "messages": []}) messages = _load_messages() filtered = _filter_by_practice(messages, pid) return JSONResponse(content={"success": True, "messages": filtered}) @@ -1187,8 +1744,9 @@ async def empfang_list(request: Request, practice_id: Optional[str] = Query(None @router.get("/thread/{thread_id}") async def empfang_thread(thread_id: str, request: Request, practice_id: Optional[str] = Query(None)): - s = _session_from_request(request) - pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) + pid = (practice_id or "").strip() or _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"success": True, "messages": []}) messages = _load_messages() thread = [m for m in messages if m.get("thread_id") == thread_id and _msg_practice(m) == pid] @@ -1235,17 +1793,17 @@ async def empfang_delete(msg_id: str): @router.get("/tasks") async def empfang_tasks_list(request: Request): - s = _session_from_request(request) - pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID + pid = _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"success": True, "tasks": []}) tasks = _load_tasks() - filtered = [t for t in tasks if t.get("practice_id", DEFAULT_PRACTICE_ID) == pid] + filtered = [t for t in tasks if t.get("practice_id") == pid] return JSONResponse(content={"success": True, "tasks": filtered}) @router.post("/tasks") async def empfang_tasks_create(request: Request): - s = _session_from_request(request) - pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID + pid = _require_practice_id(request) try: body = await request.json() except Exception: @@ -1579,9 +2137,9 @@ async def empfang_cleanup(request: Request): except Exception: body = {} max_days = int(body.get("max_age_days", 30)) - s = _session_from_request(request) - pid = (body.get("practice_id") or "").strip() or ( - s["practice_id"] if s else DEFAULT_PRACTICE_ID) + pid = (body.get("practice_id") or "").strip() or _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"success": True, "removed": 0, "remaining": 0}) cutoff = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(time.time() - max_days * 86400), @@ -1603,8 +2161,13 @@ async def empfang_cleanup(request: Request): @router.get("/practice/info") async def empfang_practice_info(request: Request): + api_token = request.headers.get("X-API-Token", "") s = _session_from_request(request) - pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID + pid = _resolve_practice_id(request) + if not pid: + return JSONResponse(content={"practice_id": "", "practice_name": "", + "user_count": 0, "message_count": 0, "open_count": 0}) + _ensure_practice(pid) users = _practice_users(pid) messages = _filter_by_practice(_load_messages(), pid) open_count = sum(1 for m in messages if m.get("status") == "offen") @@ -1617,7 +2180,7 @@ async def empfang_practice_info(request: Request): "message_count": len(messages), "open_count": open_count, } - if s and s.get("role") == "admin": + if (s and s.get("role") == "admin") or api_token: result["invite_code"] = p.get("invite_code", "") result["admin_email"] = p.get("admin_email", "") return JSONResponse(content=result) diff --git a/AzA march 2026/kg_diktat_autotext.json b/AzA march 2026/kg_diktat_autotext.json index bcaf731..93e3b7b 100644 --- a/AzA march 2026/kg_diktat_autotext.json +++ b/AzA march 2026/kg_diktat_autotext.json @@ -48,10 +48,20 @@ "dermatology" ], "ui_font_delta": 0, - "global_right_click_paste": true, + "global_right_click_paste": false, "todo_auto_open": false, "autocopy_after_diktat": true, "kommentare_auto_open": true, + "empfang_auto_open": false, + "empfang_was_open": false, + "empfang_prefs": { + "show_patient": false, + "show_ther": false, + "show_proc": false, + "show_kom": false, + "last_patient": "", + "geometry": "776x779+1604+284" + }, "medikament_quelle": "compendium.ch", "diagnose_quelle": "", "dokumente_collapsed": false, diff --git a/AzA march 2026/kg_diktat_button_heat.json b/AzA march 2026/kg_diktat_button_heat.json index 7726d0a..129def7 100644 --- a/AzA march 2026/kg_diktat_button_heat.json +++ b/AzA march 2026/kg_diktat_button_heat.json @@ -1,4 +1,7 @@ { - "⏺ Start": 33, - "Übersetzen": 1 + "⏺ Start": 23, + "🔑": 2, + "👤": 6, + "⏺ Aufnahme starten": 5, + "Diktat": 1 } \ No newline at end of file diff --git a/AzA march 2026/kg_diktat_diktat_window.txt b/AzA march 2026/kg_diktat_diktat_window.txt index e36da07..dbeed27 100644 --- a/AzA march 2026/kg_diktat_diktat_window.txt +++ b/AzA march 2026/kg_diktat_diktat_window.txt @@ -1 +1 @@ -420x380+1889+452 \ No newline at end of file +420x380+1744+1053 \ No newline at end of file diff --git a/AzA march 2026/kg_diktat_launcher_geometry.txt b/AzA march 2026/kg_diktat_launcher_geometry.txt index 35b58ce..794a698 100644 --- a/AzA march 2026/kg_diktat_launcher_geometry.txt +++ b/AzA march 2026/kg_diktat_launcher_geometry.txt @@ -1 +1 @@ -908x1041+1071+79 \ No newline at end of file +908x1041+1564+540 \ No newline at end of file diff --git a/AzA march 2026/kg_diktat_token_usage.txt b/AzA march 2026/kg_diktat_token_usage.txt index 80e007d..6ff0139 100644 --- a/AzA march 2026/kg_diktat_token_usage.txt +++ b/AzA march 2026/kg_diktat_token_usage.txt @@ -1 +1 @@ -{"used": 521654, "total": 1000000, "budget_dollars": 0, "used_dollars": 0} \ No newline at end of file +{"used": 522567, "total": 1000000, "budget_dollars": 0, "used_dollars": 0} \ No newline at end of file diff --git a/AzA march 2026/kg_diktat_user_profile.json b/AzA march 2026/kg_diktat_user_profile.json index a0faa0a..1c1cbf4 100644 --- a/AzA march 2026/kg_diktat_user_profile.json +++ b/AzA march 2026/kg_diktat_user_profile.json @@ -1,5 +1,10 @@ { "name": "André M. Surovy", "specialty": "Dermatologie", - "clinic": "Praxis Lindegut AG" + "clinic": "Praxis Lindegut AG", + "code": "", + "email": "andre.surovy@haut-winterthur.ch", + "password_hash": "$2b$12$VdOUv97Tzk7ccr2HsU632.5L5waHju1YDmw6oJnRkTWcpp/E6lBbW", + "license_key": "AZA-6TY3-63AU-W9ZR-ZO7D", + "practice_id": "prac_189854535b85" } \ No newline at end of file diff --git a/AzA march 2026/kg_diktat_window.txt b/AzA march 2026/kg_diktat_window.txt index 1301a93..b0b748c 100644 --- a/AzA march 2026/kg_diktat_window.txt +++ b/AzA march 2026/kg_diktat_window.txt @@ -1 +1 @@ -1461 1205 1189 477 638 340 +1461 1205 1189 477 717 404 diff --git a/AzA march 2026/license_status_cache.json b/AzA march 2026/license_status_cache.json index 849181e..cc3412f 100644 --- a/AzA march 2026/license_status_cache.json +++ b/AzA march 2026/license_status_cache.json @@ -1 +1 @@ -{"valid": true, "valid_until": 1777652509, "cached_at": 1775118734.4902344} \ No newline at end of file +{"valid": true, "valid_until": 1776783700, "cached_at": 1776697300.917711} \ No newline at end of file diff --git a/AzA march 2026/paned_positions.json b/AzA march 2026/paned_positions.json index 97da3f5..4dd1af1 100644 --- a/AzA march 2026/paned_positions.json +++ b/AzA march 2026/paned_positions.json @@ -1,4 +1,4 @@ { - "transcript_vertical": 340, - "kg_vertical": 329 + "transcript_vertical": 404, + "kg_vertical": 535 } \ No newline at end of file diff --git a/AzA march 2026/stripe_routes.py b/AzA march 2026/stripe_routes.py index 523d9e1..dc8da08 100644 --- a/AzA march 2026/stripe_routes.py +++ b/AzA march 2026/stripe_routes.py @@ -21,6 +21,7 @@ import smtplib import sqlite3 import string import time +import uuid from dataclasses import dataclass from decimal import Decimal from email.mime.multipart import MIMEMultipart @@ -115,6 +116,8 @@ def _ensure_storage() -> None: con.execute("ALTER TABLE licenses ADD COLUMN current_period_end INTEGER") if "license_key" not in cols: con.execute("ALTER TABLE licenses ADD COLUMN license_key TEXT") + if "practice_id" not in cols: + con.execute("ALTER TABLE licenses ADD COLUMN practice_id TEXT") con.commit() @@ -409,6 +412,59 @@ def _log_event(kind: str, payload: Dict[str, Any]) -> None: f.write(json.dumps(rec, ensure_ascii=False, default=_decimal_default) + "\n") +def _new_practice_id() -> str: + """Gleiches Format wie empfang_routes._generate_practice_id (Mandanten-ID).""" + return f"prac_{uuid.uuid4().hex[:12]}" + + +def _sync_empfang_practice_from_license( + practice_id: str, + customer_email: Optional[str], + display_name: str, +) -> None: + """Empfang-Praxisdatei mit SQLite-Lizenz synchronisieren (eine Wahrheit).""" + pid = (practice_id or "").strip() + if not pid: + return + try: + from empfang_routes import _ensure_practice + except Exception as exc: + print(f"[STRIPE] empfang_routes Import: {exc}") + return + try: + em = (customer_email or "").strip() + name = (display_name or "").strip() or (em.split("@")[0] if "@" in em else "Meine Praxis") + _ensure_practice(pid, name=name, admin_email=em) + except Exception as exc: + print(f"[STRIPE] _ensure_practice: {exc}") + + +def lookup_practice_id_for_license_email(email: str) -> Optional[str]: + """Liefert die serverseitig gespeicherte practice_id zur Kunden-E-Mail (Lizenz ↔ Praxis).""" + _ensure_storage() + e = (email or "").strip().lower() + if not e: + return None + try: + with sqlite3.connect(DB_PATH) as con: + row = con.execute( + """ + SELECT practice_id FROM licenses + WHERE lower(customer_email) = ? + AND practice_id IS NOT NULL + AND trim(practice_id) != '' + ORDER BY updated_at DESC + LIMIT 1 + """, + (e,), + ).fetchone() + if not row or not row[0]: + return None + return str(row[0]).strip() + except Exception: + return None + + def _upsert_license( *, subscription_id: str, @@ -422,27 +478,36 @@ def _upsert_license( current_period_end: Optional[int], license_key: Optional[str] = None, ) -> str: - """Upsert license row. Returns the license_key (generated if not yet set).""" + """Upsert license row. Returns the license_key (generated if not yet set). + + Jede Lizenzzeile erhält spätestens hier eine stabile practice_id (Mandant), + damit Stripe-Webhook und Desktop/Empfang dieselbe ID nutzen. + """ now = int(time.time()) with sqlite3.connect(DB_PATH) as con: existing_key = None + existing_pid = "" row = con.execute( - "SELECT license_key FROM licenses WHERE subscription_id = ?", + "SELECT license_key, practice_id FROM licenses WHERE subscription_id = ?", (subscription_id,), ).fetchone() - if row and row[0]: - existing_key = row[0] + if row: + if row[0]: + existing_key = row[0] + if row[1]: + existing_pid = str(row[1]).strip() final_key = existing_key or license_key or _generate_license_key() + final_pid = existing_pid or _new_practice_id() con.execute( """ INSERT INTO licenses( subscription_id, customer_id, status, lookup_key, allowed_users, devices_per_user, customer_email, client_reference_id, - current_period_end, updated_at, license_key + current_period_end, updated_at, license_key, practice_id ) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(subscription_id) DO UPDATE SET customer_id=excluded.customer_id, status=excluded.status, @@ -453,7 +518,12 @@ def _upsert_license( client_reference_id=COALESCE(excluded.client_reference_id, client_reference_id), current_period_end=COALESCE(excluded.current_period_end, current_period_end), updated_at=excluded.updated_at, - license_key=COALESCE(license_key, excluded.license_key) + license_key=COALESCE(license_key, excluded.license_key), + practice_id=CASE + WHEN licenses.practice_id IS NOT NULL AND trim(licenses.practice_id) != '' + THEN licenses.practice_id + ELSE excluded.practice_id + END """, ( subscription_id, @@ -467,9 +537,21 @@ def _upsert_license( current_period_end, now, final_key, + final_pid, ), ) con.commit() + + row2 = con.execute( + "SELECT practice_id, customer_email FROM licenses WHERE subscription_id = ?", + (subscription_id,), + ).fetchone() + if row2: + pid_s = str(row2[0]).strip() if row2[0] else "" + em = (str(row2[1]).strip() if row2[1] else "") or (customer_email or "").strip() + if pid_s: + disp = em.split("@")[0] if "@" in em else "Meine Praxis" + _sync_empfang_practice_from_license(pid_s, em or None, disp) return final_key diff --git a/AzA march 2026/web/empfang.html b/AzA march 2026/web/empfang.html index 951e386..22bb6fc 100644 --- a/AzA march 2026/web/empfang.html +++ b/AzA march 2026/web/empfang.html @@ -183,6 +183,19 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra .login-switch{text-align:center;margin-top:14px;font-size:.82rem;color:#6a8a9a} .login-switch a{color:#5B8DB3;cursor:pointer;text-decoration:underline} +/* Registrierung: Überschrift, Fließtext, Labels, Felder, Button, Link – alles 9pt */ +.login-box.login-register, +.login-box.login-register h2, +.login-box.login-register p, +.login-box.login-register label, +.login-box.login-register input, +.login-box.login-register select, +.login-box.login-register button, +.login-box.login-register .login-error, +.login-box.login-register .login-switch, +.login-box.login-register .login-switch a{font-size:9pt!important} +.login-box.login-register h2{font-weight:600} + /* === 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} @@ -386,7 +399,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
Aktualisiert alle 10 Sek. - v2026.04.18b + v2026.04.20