Files
aza/AzA march 2026/aza_doku_vorlagen.py
2026-06-13 22:47:31 +02:00

2357 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
aza_doku_vorlagen.py — Doku-Prompt-Fenster (AzA-Stil)
Zentrale Prompt-Verwaltung fuer medizinische Dokumente (UI: Doku-Prompt).
Interne Datei/IDs bleiben aus Kompatibilitaetsgruenden unveraendert.
Kernprinzipien:
- AzA-Grundvorlage ist Systemvorlage und wird nicht direkt ueberschrieben.
- Benutzer darf den Inhalt im Editor bearbeiten und als neue Vorlage speichern.
- Immer genau eine aktive Vorlage pro Dokumenttyp.
- Alle Daten lokal in aza_document_templates.json.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import time
import tkinter as tk
from datetime import datetime, timezone
from tkinter import ttk
from typing import Any
from aza_ui_helpers import (
aza_askstring,
aza_askyesno,
aza_showerror,
aza_showinfo,
aza_showwarning,
make_modal_topmost,
release_modal_dialog,
)
SCHEMA_VERSION = 2
MAX_PROMPT_CHARS = 32000
# ── AzA-Designkonstanten (identisch mit KI-Guthaben-Fenster) ──────────────────
_BG = "#E8F4FA"
_HDR_BG = "#1A4D6D"
_HDR_FG = "#FFFFFF"
_SUB_BG = "#D0E8F5"
_CARD_BG = "#FFFFFF"
_CARD_BD = "#C8D8E8"
_ACCENT = "#5B8DB3"
_TEXT = "#1A3D55"
_TEXT_SUB = "#607890"
_BTN_BLUE = "#5B8DB3"
_BTN_ACT = "#5B8DB3" # Einheitlich AzA-Blau (wie Speichern/Als neue Vorlage)
_BTN_RESET = "#C04040" # Rot nur fuer destruktive Aktion "Zurücksetzen"
_BTN_CLOSE = "#5B8DB3" # Einheitlich AzA-Blau
_FF = "Segoe UI"
# ── Dokumenttypen (Reihenfolge exakt laut Spezifikation) ─────────────────────
DOC_TYPES = [
("kg", "Krankengeschichte"),
("verlauf", "Verlauf"),
("brief", "Brief"),
("rezept", "Rezept"),
("op_bericht", "OP-Bericht"),
("kogu", "KOGU"),
("arztzeugnis", "Arztzeugnis"),
("eigenes_dokument", "Eigenes Dokument"),
]
DOC_TYPE_KEYS = [dt for dt, _ in DOC_TYPES]
# Status-Label pro Dokumenttyp (für dynamische "Erstelle …"-Statusmeldung).
DOC_TYPE_STATUS_LABELS = {
"kg": "Krankengeschichte",
"verlauf": "Verlauf",
"brief": "Brief",
"rezept": "Rezept",
"op_bericht": "OP-Bericht",
"kogu": "KOGU",
"arztzeugnis": "Arztzeugnis",
"eigenes_dokument": "Eigenes Dokument",
}
def doc_type_status_label(doc_type: str) -> str:
"""Reines Mapping doc_type → Anzeigename für Statusmeldungen."""
return DOC_TYPE_STATUS_LABELS.get(doc_type or "kg", "Krankengeschichte")
def resolve_doc_generation_route(doc_type: str) -> str:
"""Reines Routing: welcher Generierungspfad gehört zum Dokumenttyp.
Rückgabe:
"kg" → Krankengeschichte-Generator (summarize_text)
"form" → Formularpfad (Arztzeugnis)
"generate" → generate_document_to_main_field(doc_type)
"""
dt = doc_type or "kg"
if dt == "kg":
return "kg"
if dt == "arztzeugnis":
return "form"
if dt in ("verlauf", "brief", "rezept", "op_bericht", "kogu", "eigenes_dokument"):
return "generate"
return "kg"
DOC_TYPE_CREATE_LABELS = {
"kg": "KG erstellen",
"verlauf": "Verlauf erstellen",
"brief": "Brief erstellen",
"rezept": "Rezept erstellen",
"op_bericht": "OP-Bericht erstellen",
"kogu": "KOGU erstellen",
"arztzeugnis": "Arztzeugnis erstellen",
"eigenes_dokument": "Dokument erstellen",
}
DOC_TYPE_COPY_LABELS = {
"kg": "KG kopieren",
"verlauf": "Verlauf kopieren",
"brief": "Brief kopieren",
"rezept": "Rezept kopieren",
"op_bericht": "OP-Bericht kopieren",
"kogu": "KOGU kopieren",
"arztzeugnis": "Arztzeugnis kopieren",
"eigenes_dokument": "Dokument kopieren",
}
# Kurzlabels fürs Hauptfenster (verhindern abgeschnittene Buttons bei langen Typen).
DOC_TYPE_CREATE_LABELS_SHORT = {
"kg": "KG erstellen",
"verlauf": "Verl. erst.",
"brief": "Brief erst.",
"rezept": "Rezept erst.",
"op_bericht": "OP erst.",
"kogu": "KOGU erst.",
"arztzeugnis": "Zeugnis",
"eigenes_dokument": "Eig. Dok. erst.",
}
DOC_TYPE_COPY_LABELS_SHORT = {
"kg": "KG kopieren",
"verlauf": "Verl. kop.",
"brief": "Brief kop.",
"rezept": "Rezept kop.",
"op_bericht": "OP kop.",
"kogu": "KOGU kop.",
"arztzeugnis": "Zeugnis kop.",
"eigenes_dokument": "Eig. Dok. kop.",
}
def save_local_publish_payload(payload: dict) -> None:
"""Bereitet eine Veröffentlichung lokal vor (Queue im Daten-Verzeichnis).
So geht beim fehlenden Server-Deploy nichts verloren und es entsteht keine
falsche Erfolgsmeldung.
"""
try:
import os
from aza_config import get_writable_data_dir
path = os.path.join(get_writable_data_dir(), "doku_prompt_publish_queue.json")
existing = []
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as fh:
existing = json.load(fh) or []
except Exception:
existing = []
if not isinstance(existing, list):
existing = []
existing.append(payload)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(existing, fh, ensure_ascii=False, indent=2)
os.replace(tmp, path)
except Exception:
pass
def _publish_queue_path() -> str:
from aza_config import get_writable_data_dir
return os.path.join(get_writable_data_dir(), "doku_prompt_publish_queue.json")
def _read_publish_queue() -> list[dict[str, Any]]:
try:
path = _publish_queue_path()
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8") as fh:
raw = json.load(fh) or []
return raw if isinstance(raw, list) else []
except Exception:
return []
def _write_publish_queue(items: list[dict[str, Any]]) -> None:
path = _publish_queue_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(items, fh, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def update_local_publish_queue_item(queue_index: int, updates: dict[str, Any]) -> bool:
"""Aktualisiert einen Eintrag der lokalen Publish-Queue (Metadaten-Bearbeitung)."""
try:
items = _read_publish_queue()
if queue_index < 0 or queue_index >= len(items):
return False
if not isinstance(items[queue_index], dict):
return False
items[queue_index].update(updates)
_write_publish_queue(items)
return True
except Exception:
return False
def remove_local_publish_queue_item(queue_index: int) -> bool:
"""Entfernt einen Eintrag aus der lokalen Publish-Queue (eigene Veröffentlichung)."""
try:
items = _read_publish_queue()
if queue_index < 0 or queue_index >= len(items):
return False
del items[queue_index]
_write_publish_queue(items)
return True
except Exception:
return False
def public_item_search_text(item: dict[str, Any]) -> str:
"""Suchtext für lokale Filterung öffentlicher Vorlagen."""
if not isinstance(item, dict):
return ""
parts = [
item.get("doc_type") or "",
doc_type_label(item.get("doc_type") or ""),
item.get("title") or "",
item.get("author_display") or item.get("author") or "",
item.get("location") or item.get("city") or "",
item.get("specialty") or "",
item.get("description") or "",
item.get("content") or "",
]
return " ".join(str(p) for p in parts).lower()
def filter_public_items(items: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""Filtert öffentliche Vorlagen lokal nach Metadaten und Prompttext."""
q = (query or "").strip().lower()
if not q:
return list(items)
return [it for it in items if isinstance(it, dict) and q in public_item_search_text(it)]
def load_local_prepared_public_items() -> list[dict[str, Any]]:
"""Lokale Publish-Queue als Platzhalter für öffentliche Vorlagen (kein Server-Deploy)."""
try:
raw = _read_publish_queue()
items: list[dict[str, Any]] = []
for idx, p in enumerate(raw):
if not isinstance(p, dict):
continue
dt = p.get("document_type_key") or ""
items.append({
"doc_type": dt,
"title": p.get("title") or "Ohne Titel",
"author_display": p.get("author_display_name") or "",
"author": p.get("author_display_name") or "",
"author_initials": p.get("author_initials") or "",
"author_user_id": p.get("author_user_id") or "",
"location": p.get("region") or "",
"city": p.get("region") or "",
"specialty": p.get("specialty") or "",
"description": p.get("description") or "",
"content": p.get("prompt_text") or "",
"id": p.get("source_template_id") or "",
"server_id": "",
"_local_prepared": True,
"_queue_index": idx,
})
return items
except Exception:
return []
def resolve_current_user_id(app: Any) -> str:
uid, _ = _app_user_practice_ids(app)
return uid
def is_own_public_item(app: Any, item: dict[str, Any]) -> bool:
"""Eigene Veröffentlichung erkennen (user_id oder Autoren-Fallback bei lokaler Queue)."""
if not isinstance(item, dict):
return False
uid = resolve_current_user_id(app)
item_uid = str(item.get("author_user_id") or "").strip()
if uid and item_uid:
return item_uid == uid
if item.get("_local_prepared"):
me = resolve_default_author_display(app).strip().lower()
theirs = str(item.get("author_display") or item.get("author") or "").strip().lower()
if me and theirs and me == theirs:
return True
return False
def format_rating_average_de(avg: float | None) -> str:
if avg is None:
return ""
return f"{float(avg):.1f}".replace(".", ",")
def format_rating_count_label(count: int) -> str:
n = int(count or 0)
if n == 1:
return "1 Bewertung"
return f"{n} Bewertungen"
def render_star_unicode(avg: float | None) -> str:
if avg is None or float(avg) <= 0:
return "☆☆☆☆☆"
steps = max(0, min(10, int(round(float(avg) * 2))))
full = steps // 2
half = steps % 2 == 1
chars = "" * full
if half:
chars += "½"
chars += "" * (5 - full - (1 if half else 0))
return chars
def format_public_rating_summary(avg: float | None, count: int) -> str:
n = int(count or 0)
if n <= 0:
return "Noch keine Bewertungen · 0 Bewertungen"
return f"{render_star_unicode(avg)} {format_rating_average_de(avg)} · {format_rating_count_label(n)}"
def format_public_rating_list_text(avg: float | None, count: int) -> str:
n = int(count or 0)
if n <= 0:
return "Noch keine Bewertungen"
return f"{render_star_unicode(avg)} {format_rating_average_de(avg)} ({format_rating_count_label(n)})"
def _apply_rating_fields_to_item(item: dict[str, Any], payload: dict[str, Any]) -> None:
if not isinstance(item, dict) or not isinstance(payload, dict):
return
for key in ("rating_average", "rating_count", "my_rating", "can_rate"):
if key in payload:
item[key] = payload[key]
def build_half_star_rating_bar(
parent: tk.Misc,
*,
bg: str = _SUB_BG,
on_save,
) -> dict[str, Any]:
"""Interaktive Halbstern-Leiste (Klick waehlt, Speichern sendet)."""
frame = tk.Frame(parent, bg=bg)
state: dict[str, Any] = {"value": 0.0, "enabled": True, "busy": False}
value_var = tk.StringVar(value="")
star_canvas = tk.Canvas(frame, width=118, height=26, bg=bg, highlightthickness=0, cursor="hand2")
star_canvas.pack(side="left")
btn_save = tk.Button(
frame, text="Speichern", font=(_FF, 8), bg=_BTN_BLUE, fg="white",
activebackground="#4A7A9B", relief="flat", padx=8, pady=2, cursor="hand2",
)
btn_save.pack(side="left", padx=(8, 0))
def _draw() -> None:
star_canvas.delete("all")
val = float(state.get("value") or 0)
for i in range(5):
x0 = i * 22 + 2
mid = x0 + 11
left_active = val >= (i + 0.5)
right_active = val >= (i + 1.0)
star_canvas.create_text(
mid - 5, 13, text="" if left_active else "",
font=(_FF, 13), fill="#C8921A" if left_active else "#B0BEC5",
)
star_canvas.create_text(
mid + 5, 13, text="" if right_active else "",
font=(_FF, 13), fill="#C8921A" if right_active else "#B0BEC5",
)
if val > 0:
value_var.set(f"{format_rating_average_de(val)} Sterne")
else:
value_var.set("Bitte Sterne wählen")
def _pick(event) -> None:
if not state.get("enabled") or state.get("busy"):
return
try:
x = max(0, min(109, int(event.x)))
except Exception:
return
step = int(x / 11) + 1
state["value"] = min(5.0, max(0.5, step * 0.5))
_draw()
def _save() -> None:
if not state.get("enabled") or state.get("busy"):
return
val = float(state.get("value") or 0)
if val <= 0:
return
state["busy"] = True
try:
btn_save.config(state="disabled")
except Exception:
pass
try:
on_save(val)
finally:
state["busy"] = False
try:
btn_save.config(state="normal" if state.get("enabled") else "disabled")
except Exception:
pass
star_canvas.bind("<Button-1>", _pick)
btn_save.config(command=_save)
def set_value(v: float | None) -> None:
state["value"] = float(v or 0)
_draw()
def set_enabled(enabled: bool) -> None:
state["enabled"] = bool(enabled)
try:
star_canvas.config(cursor="hand2" if enabled else "arrow")
btn_save.config(state="normal" if enabled else "disabled")
except Exception:
pass
_draw()
return {"frame": frame, "value_var": value_var, "set_value": set_value, "set_enabled": set_enabled}
def refresh_doku_prompt_window(app: Any, doc_type: str | None = None, tpl_id: str | None = None) -> None:
"""Aktualisiert das offene Doku-Prompt-Fenster (Dropdown nach Übernahme)."""
cb = getattr(app, "_doku_vorlage_refresh_cb", None)
if not callable(cb):
return
def _do_refresh():
try:
cb(doc_type, tpl_id)
except Exception:
pass
try:
app.after(0, _do_refresh)
except Exception:
_do_refresh()
def adopt_public_template_to_own(app: Any, item: dict[str, Any], *, activate: bool = True) -> str | None:
"""Übernimmt öffentliche Vorlage in denselben Speicherpfad wie „Als neue Vorlage“."""
dt = (item.get("doc_type") or "kg").strip()
if dt not in DOC_TYPE_KEYS:
dt = "kg"
uid, pid = _app_user_practice_ids(app)
new_id = copy_public_template_as_own(
dt,
content=item.get("content") or "",
title=item.get("title") or "Übernommene Vorlage",
description=item.get("description") or "",
source_author=item.get("author_display") or item.get("author") or "",
source_server_id=item.get("id") or item.get("server_id") or "",
user_id=uid,
practice_id=pid,
activate=activate,
)
if new_id:
refresh_doku_prompt_window(app, doc_type=dt, tpl_id=new_id)
return new_id
VERLAUF_DEFAULT_PROMPT = (
"Erstelle aus dem Transkript einen präzisen medizinischen Verlaufsbericht. "
"Beschreibe die Entwicklung seit der letzten Konsultation, den aktuellen subjektiven Zustand, "
"relevante neue oder persistierende Beschwerden, objektive Befunde, relevante Resultate, "
"die Wirkung und Verträglichkeit der bisherigen Therapie, erfolgte Therapieanpassungen "
"sowie das weitere Vorgehen. Schreibe chronologisch, sachlich und knapp. "
"Wiederhole die vollständige Anamnese nicht unnötig. "
"Verwende ausschliesslich Angaben aus dem Transkript und dem vorhandenen Kontext. "
"Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
"Gliedere sinnvoll in Verlauf, Befunde, Beurteilung und weiteres Vorgehen, "
"sofern genügend Informationen vorhanden sind."
)
EIGENES_DOKUMENT_DEFAULT_PROMPT = (
"Erstelle aus dem Transkript ein medizinisches Dokument gemäss den untenstehenden "
"Anweisungen. Verwende ausschliesslich Informationen aus dem Transkript und dem "
"vorhandenen Kontext. Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
"Schreibe sachlich, professionell und in Schweizer medizinischer Schreibweise."
)
# ── AzA-Grundvorlagen (verständlich formuliert, für Ärzte) ────────────────────
AZA_DEFAULT_TEMPLATES: dict[str, str] = {
"kg": (
"Reihenfolge: 1. Diagnose, 2. Anamnese, 3. Befund, 4. Therapie, 5. Procedere\n\n"
"Schreibregeln:\n"
"- Aus der Transkription eine klare medizinische Krankengeschichte erstellen.\n"
"- Diagnosen nummerieren (1. 2. 3. ...). KEIN Bulletpoint vor der Nummer.\n"
" Richtig: 1. Diagnose Falsch: • 1. Diagnose\n"
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt unter die\n"
" passende Diagnose schreiben, wenn eindeutig zuordenbar.\n"
"- Unklare Therapien separat unter 'Therapie' aufführen.\n"
"- Verlauf, Kontrollen und Wiedervorstellung unter 'Procedere', nicht unter 'Therapie'.\n"
"- Medikamente mit Wirkstoff in Klammern, wenn sicher bekannt:\n"
" Dermovate Creme (Clobetasol), Dafalgan (Paracetamol), Fucidin (Fusidinsäure).\n"
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden. Keine ICD-Codes erfinden.\n"
"- Keine Diagnosen, Therapien oder Daten erfinden.\n"
"- Keine genannten Diagnosen weglassen.\n"
"- Unterpunkte unter Diagnosen einrücken (Bindestrich).\n"
"- Schreibstil: medizinisch, professionell, kompakt.\n\n"
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter Diagnose,\n"
"Procedere getrennt) wird zusätzlich durch das Häkchen 'Diagnosen gliedern' gesteuert."
),
"brief": (
"Reihenfolge: 1. Diagnose, 2. Anlass, 3. Befunde, 4. Therapie, 5. Procedere/Empfehlung\n\n"
"Schreibregeln:\n"
"- Diagnosen nummerieren (1. 2. 3. ...).\n"
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt\n"
" unter die passende Diagnose schreiben.\n"
"- Verlauf, Kontrollen und Wiedervorstellung unter Procedere,\n"
" nicht unter Therapie.\n"
"- Medikamente mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
" Beispiel: Dermovate Creme (Clobetasol), Dafalgan (Paracetamol).\n"
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden.\n"
"- Nichts erfinden. Medizinisch, professionell und kompakt schreiben.\n\n"
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter\n"
"Diagnose, Procedere getrennt) wird zusätzlich durch das Häkchen\n"
"'Diagnosen gliedern' gesteuert."
),
"rezept": (
"Rezeptvorlage Schreibregeln:\n\n"
"- Nur Medikamente aufführen, die im Transkript genannt wurden.\n"
"- Dosierung nur aus dem Transkript übernehmen, nichts erfinden.\n"
"- Markenname mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
" Beispiel: Dermovate Creme (Clobetasol) 2x täglich.\n"
"- Generikum bevorzugen, wenn nur der Wirkstoff genannt wurde.\n"
"- Keine ICD-Codes erfinden, wenn nicht vorhanden."
),
"op_bericht": (
"OP-Bericht / Eingriffsbericht Reihenfolge:\n\n"
"1. Diagnose (mit ICD-Code, falls vorhanden)\n"
"2. Indikation\n"
"3. Eingriff / Prozedur\n"
"4. Befund / Histologie\n"
"5. Verlauf / Procedere\n\n"
"Schreibregeln:\n"
"- 'Status nach' als Standard, nicht 'Zustand nach'.\n"
"- Keine Befunde oder Histologien erfinden.\n"
"- ICD-Codes nur übernehmen, wenn im Transkript genannt.\n"
"- Medizinisch korrekter Fachstil."
),
"kogu": (
"Kostengutsprache Reihenfolge:\n\n"
"1. Patient / Versicherung\n"
"2. Diagnose (ICD-Code falls vorhanden)\n"
"3. Klinische Begründung / Notwendigkeit\n"
"4. Beantragte Massnahme / Behandlung\n"
"5. Kosten / Tarifpositionen (nur wenn im Transkript genannt)\n"
"6. Datum / Unterschrift\n\n"
"Schreibregeln:\n"
"- Kosten und Tarifpositionen nur aufnehmen, wenn genannt.\n"
"- Medizinische Begründung sachlich und klinisch formulieren.\n"
"- Keine Diagnosen oder Befunde erfinden."
),
"arztzeugnis": (
"Arztzeugnis Reihenfolge:\n\n"
"1. Angaben Patient/in\n"
"2. Diagnose (ICD-Code falls vorhanden)\n"
"3. Behandlungszeitraum\n"
"4. Arbeitsfähigkeit / Arbeitsunfähigkeit\n"
"5. Besondere Anmerkungen (nur aus Transkript)\n"
"6. Datum, Arzt/Ärztin\n\n"
"Schreibregeln:\n"
"- Zeiträume nur aus Transkript übernehmen, nichts erfinden.\n"
"- Diagnose medizinisch korrekt, ICD-Code wenn vorhanden.\n"
"- Sachlicher, formeller Schreibstil."
),
"verlauf": VERLAUF_DEFAULT_PROMPT,
"eigenes_dokument": EIGENES_DOKUMENT_DEFAULT_PROMPT,
}
# ── Persistenz ────────────────────────────────────────────────────────────────
def _templates_json_path() -> str:
try:
from aza_config import get_writable_data_dir
return os.path.join(get_writable_data_dir(), "aza_document_templates.json")
except Exception:
return os.path.join(os.path.expanduser("~"), "AppData", "Roaming",
"AZA Desktop", "aza_document_templates.json")
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sanitize_prompt_text(text: str) -> str:
"""Plaintext only — keine HTML/Script-Injection."""
if not text:
return ""
s = str(text)
s = re.sub(r"(?is)<script[^>]*>.*?</script>", "", s)
s = re.sub(r"(?is)<style[^>]*>.*?</style>", "", s)
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"(?i)javascript\s*:", "", s)
s = re.sub(r"(?i)data\s*:", "", s)
s = s.replace("\x00", "")
if len(s) > MAX_PROMPT_CHARS:
s = s[:MAX_PROMPT_CHARS]
return s.strip()
def _new_template_entry(doc_type: str, *, now: str | None = None) -> dict[str, Any]:
ts = now or _utc_now()
return {
"id": "aza_default",
"name": "AzA-Grundvorlage",
"is_system": True,
"content": AZA_DEFAULT_TEMPLATES.get(doc_type, ""),
"description": "",
"created_at": ts,
"updated_at": ts,
"revision": 1,
"user_id": "",
"practice_id": "",
"server_id": "",
"published": False,
"sync_updated_at": "",
"source_author": "",
"source_server_id": "",
}
def _ensure_template_fields(t: dict[str, Any]) -> dict[str, Any]:
if not isinstance(t, dict):
return _new_template_entry("kg")
t.setdefault("description", "")
t.setdefault("revision", 1)
t.setdefault("user_id", "")
t.setdefault("practice_id", "")
t.setdefault("server_id", "")
t.setdefault("published", False)
t.setdefault("sync_updated_at", "")
t.setdefault("source_author", "")
t.setdefault("source_server_id", "")
if "content" in t:
t["content"] = sanitize_prompt_text(t.get("content") or "")
return t
def _default_structure() -> dict[str, Any]:
now = _utc_now()
return {
"schema_version": SCHEMA_VERSION,
"active": {dt: "aza_default" for dt, _ in DOC_TYPES},
"templates": {
dt: [_new_template_entry(dt, now=now)]
for dt, _ in DOC_TYPES
},
"sync_meta": {"last_sync_at": "", "conflicts": []},
}
def _backup_json_before_migration(path: str) -> None:
if not os.path.isfile(path):
return
try:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
shutil.copy2(path, path + f".pre_migrate_{stamp}.bak")
except Exception:
pass
def _migrate_data(d: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migration schema v1→v2: fehlende Typen ergänzen, Metadaten anreichern."""
changed = False
now = _utc_now()
sv = int(d.get("schema_version") or 1)
for dt, _ in DOC_TYPES:
if dt not in d.get("templates", {}):
d.setdefault("templates", {})[dt] = [_new_template_entry(dt, now=now)]
changed = True
if dt not in d.get("active", {}):
d.setdefault("active", {})[dt] = "aza_default"
changed = True
tpls = d.get("templates", {}).get(dt, [])
for i, t in enumerate(tpls):
if isinstance(t, dict):
before = json.dumps(t, sort_keys=True)
tpls[i] = _ensure_template_fields(t)
if json.dumps(tpls[i], sort_keys=True) != before:
changed = True
if "sync_meta" not in d:
d["sync_meta"] = {"last_sync_at": "", "conflicts": []}
changed = True
if sv < SCHEMA_VERSION:
d["schema_version"] = SCHEMA_VERSION
changed = True
return d, changed
def _load() -> dict[str, Any]:
path = _templates_json_path()
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
d = json.load(f)
if isinstance(d, dict) and d.get("schema_version") in (1, 2):
d, changed = _migrate_data(d)
if changed:
try:
_backup_json_before_migration(path)
_save(d)
except Exception:
pass
return d
except Exception:
pass
return _default_structure()
def _load_all_templates() -> dict[str, Any]:
"""Alias fuer externe Module (z. B. aza_text_windows_mixin)."""
return _load()
def _save(data: dict[str, Any]) -> None:
path = _templates_json_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp, path)
def get_active_template_content(doc_type: str) -> str:
try:
data = _load()
aid = data.get("active", {}).get(doc_type, "aza_default")
for t in data.get("templates", {}).get(doc_type, []):
if isinstance(t, dict) and t.get("id") == aid:
return sanitize_prompt_text(t.get("content") or "")
except Exception:
pass
return AZA_DEFAULT_TEMPLATES.get(doc_type, "")
def get_active_template_id(doc_type: str) -> str:
try:
data = _load()
return str(data.get("active", {}).get(doc_type, "aza_default"))
except Exception:
return "aza_default"
def doc_type_label(doc_type: str) -> str:
for dt, label in DOC_TYPES:
if dt == doc_type:
return label
return doc_type
def copy_public_template_as_own(
doc_type: str,
*,
content: str,
title: str,
description: str = "",
source_author: str = "",
source_server_id: str = "",
user_id: str = "",
practice_id: str = "",
activate: bool = False,
) -> str | None:
"""Übernimmt eine öffentliche Vorlage als neue eigene Kopie (neue ID)."""
if doc_type not in DOC_TYPE_KEYS:
doc_type = "kg"
data = _load()
name = (title or "Übernommene Vorlage").strip()
new_id = f"user_{int(time.time())}_{doc_type}"
now = _utc_now()
new_tpl = {
"id": new_id,
"name": name,
"is_system": False,
"content": sanitize_prompt_text(content),
"description": (description or "").strip(),
"created_at": now,
"updated_at": now,
"revision": 1,
"user_id": (user_id or "").strip(),
"practice_id": (practice_id or "").strip(),
"server_id": "",
"published": False,
"sync_updated_at": "",
"source_author": (source_author or "").strip(),
"source_server_id": (source_server_id or "").strip(),
}
data.setdefault("templates", {}).setdefault(doc_type, []).append(new_tpl)
if activate:
data.setdefault("active", {})[doc_type] = new_id
_save(data)
return new_id
# ── Sync / Veröffentlichung (Client, kein Deploy in diesem Block) ─────────────
def _app_user_practice_ids(app: Any) -> tuple[str, str]:
user_id = ""
practice_id = ""
try:
practice_id = (app.get_practice_id() or "").strip()
except Exception:
pass
try:
user_id = (getattr(app, "_empfang_self_user_id", lambda: "")() or "").strip()
except Exception:
user_id = ""
return user_id, practice_id
def sync_private_doku_prompts(app: Any) -> bool:
"""Synchronisiert private Benutzer-Prompts mit dem Server (falls erreichbar)."""
try:
from aza_doku_prompt_sync import sync_doku_prompts_with_server
return sync_doku_prompts_with_server(app)
except ImportError:
return False
except Exception:
return False
def publish_template_to_server(app: Any, doc_type: str, tpl: dict[str, Any],
title: str, description: str = "") -> tuple[bool, str]:
"""Veröffentlicht aktuelle Vorlage auf dem Server (nur wenn angemeldet)."""
try:
from aza_doku_prompt_sync import publish_doku_prompt
return publish_doku_prompt(app, doc_type, tpl, title, description)
except ImportError:
return False, "Sync-Modul nicht verfügbar."
except Exception as e:
return False, str(e)
def fetch_public_prompt_library(app: Any) -> list[dict[str, Any]]:
try:
from aza_doku_prompt_sync import fetch_public_doku_prompts
return fetch_public_doku_prompts(app)
except ImportError:
return []
except Exception:
return []
def fetch_public_prompt_library_result(app: Any) -> tuple[list[dict[str, Any]], str | None]:
try:
from aza_doku_prompt_sync import fetch_public_doku_prompts_result
return fetch_public_doku_prompts_result(app)
except ImportError:
return [], None
except Exception:
return [], "__CONN_ERROR__"
# ── Fenster-Hilfen (analog aza_bibliothek_ui) ─────────────────────────────────
_DOKU_WIN_W, _DOKU_WIN_H = 1080, 820
_DOKU_WIN_MIN_W, _DOKU_WIN_MIN_H = 980, 700
_PUBLIC_WIN_W, _PUBLIC_WIN_H = 1180, 860
_PUBLIC_WIN_MIN_W, _PUBLIC_WIN_MIN_H = 1050, 760
_PUBLISH_DLG_W, _PUBLISH_DLG_H = 720, 860
_PUBLISH_DLG_MIN_W, _PUBLISH_DLG_MIN_H = 650, 760
_PUBLISH_ROUTE_MSG = (
"Öffentliche Veröffentlichung ist lokal vorbereitet, "
"benötigt aber noch den Server-Deploy."
)
_PUBLIC_LIB_PLACEHOLDER = (
"Öffentliche Vorlagen sind lokal vorbereitet. "
"Die Live-Bibliothek benötigt noch den Server-Deploy."
)
def _doku_center_top(win: tk.Toplevel, w: int, h: int, *, parent=None) -> None:
from aza_ui_helpers import center_tool_window
center_tool_window(win, w, h, parent=parent, y_ratio=0.06)
def _doku_bring_to_front(win: tk.Toplevel) -> None:
from aza_ui_helpers import bring_tool_window_to_front
bring_tool_window_to_front(win)
def _apply_tool_window_geometry(
win: tk.Toplevel, w: int, h: int, min_w: int, min_h: int, *, parent=None,
) -> None:
"""AzA-Werkzeugfenster: zentriert, im Vordergrund, Höhe begrenzt (Action-Bar sichtbar)."""
try:
win.update_idletasks()
sh = win.winfo_screenheight()
h = min(h, max(min_h, sh - 80))
except Exception:
pass
win.minsize(min_w, min_h)
_doku_center_top(win, w, h, parent=parent)
_doku_bring_to_front(win)
def _profile_field(app: Any, keys: tuple[str, ...], default: str = "") -> str:
try:
prof = getattr(app, "_user_profile", {}) or {}
for k in keys:
v = str(prof.get(k) or "").strip()
if v:
return v
except Exception:
pass
return default
def _author_initials(name: str) -> str:
parts = [p for p in (name or "").split() if p]
if not parts:
return "?"
if len(parts) == 1:
return parts[0][:2].upper()
return f"{parts[0][0]}{parts[-1][0]}".upper()
def resolve_default_author_display(app: Any) -> str:
try:
from aza_doku_prompt_sync import resolve_author_display
return resolve_author_display(app)
except Exception:
return _profile_field(app, ("display_name", "name"))
def resolve_default_location(app: Any) -> str:
return _profile_field(
app,
("city", "location", "ort", "practice_city", "practice_location"),
"Winterthur",
)
def resolve_default_specialty(app: Any) -> str:
return _profile_field(
app,
("specialty", "fachrichtung", "specialisation", "fachgebiet"),
"Dermatologie",
)
def resolve_default_author_initials(app: Any) -> str:
author = resolve_default_author_display(app)
initials = _author_initials(author)
return initials if initials and initials != "?" else "A.S."
def _wire_text_dictation(
app: Any,
parent: tk.Misc,
text_widget: tk.Text,
status_var: tk.StringVar,
btn_rec: tk.Button,
) -> None:
"""Mini-Diktat in ein Textfeld — gleiches Muster wie Arztzeugnis-Fenster."""
recorder: list[Any] = [None]
is_recording = [False]
def _toggle():
if is_recording[0]:
is_recording[0] = False
btn_rec.configure(text="⏺ Diktieren", bg=_BTN_BLUE)
status_var.set("Transkribiere …")
rec = recorder[0]
def _worker():
try:
wav_path = rec.stop_and_save_wav() if rec else None
if not wav_path:
app.after(0, lambda: status_var.set("Aufnahme fehlgeschlagen."))
return
text = app.transcribe_wav(wav_path)
if not text:
app.after(0, lambda: status_var.set("Kein Text erkannt."))
return
def _insert():
cur = text_widget.get("1.0", "end-1c").strip()
if cur:
text_widget.insert("end", "\n" + text)
else:
text_widget.insert("1.0", text)
status_var.set("Diktat eingefügt.")
app.after(0, _insert)
except Exception as exc: # noqa: BLE001
app.after(0, lambda: status_var.set(f"Fehler: {exc}"))
import threading
threading.Thread(target=_worker, daemon=True).start()
return
mic_ok = True
try:
mic_ok = bool(app._ensure_microphone_ready())
except Exception:
pass
if not mic_ok:
status_var.set("Mikrofon nicht bereit.")
return
try:
from aza_audio import AudioRecorder
recorder[0] = AudioRecorder()
recorder[0].start()
except Exception as exc:
status_var.set(f"Fehler: {exc}")
return
is_recording[0] = True
btn_rec.configure(text="⏹ Stoppen", bg="#C03030")
status_var.set("Aufnahme läuft …")
btn_rec.configure(command=_toggle)
def open_doku_publish_dialog(
app: Any,
parent: tk.Misc,
*,
doc_type: str,
default_title: str,
on_confirm,
mode: str = "publish",
initial_values: dict[str, Any] | None = None,
confirm_label: str = "Veröffentlichen",
window_title: str = "Vorlage veröffentlichen",
) -> None:
"""AzA-Werkzeugfenster für Veröffentlichungs-Metadaten (kein simpledialog)."""
iv = initial_values or {}
start_dt = (iv.get("doc_type") or doc_type or "kg").strip()
if start_dt not in DOC_TYPE_KEYS:
start_dt = doc_type if doc_type in DOC_TYPE_KEYS else "kg"
dlg = tk.Toplevel(parent)
dlg.title(window_title)
dlg.configure(bg=_BG)
dlg.resizable(True, True)
_apply_tool_window_geometry(
dlg, _PUBLISH_DLG_W, _PUBLISH_DLG_H, _PUBLISH_DLG_MIN_W, _PUBLISH_DLG_MIN_H, parent=parent,
)
make_modal_topmost(dlg, parent)
hdr = tk.Frame(dlg, bg=_HDR_BG)
hdr.pack(side="top", fill="x")
tk.Label(hdr, text=window_title, font=(_FF, 12, "bold"),
bg=_HDR_BG, fg=_HDR_FG, padx=16, pady=10).pack(side="left")
btn_row = tk.Frame(dlg, bg="#E8F0F5", pady=10)
btn_row.pack(side="bottom", fill="x")
body = tk.Frame(dlg, bg=_BG, padx=16, pady=10)
body.pack(side="top", fill="both", expand=True)
body.columnconfigure(1, weight=1)
def _lbl(row, text):
tk.Label(body, text=text, font=(_FF, 9, "bold"),
bg=_BG, fg=_TEXT, anchor="w").grid(row=row, column=0, sticky="w", pady=(6, 2))
def _ent(row, value=""):
var = tk.StringVar(value=value)
ent = tk.Entry(body, textvariable=var, font=(_FF, 9), bg="#FFFFFF", fg=_TEXT,
relief="flat", highlightbackground=_CARD_BD, highlightthickness=1)
ent.grid(row=row, column=1, sticky="ew", pady=(6, 2), ipady=4)
return var
doc_labels = [lbl for _, lbl in DOC_TYPES]
doc_label_var = tk.StringVar(value=doc_type_label(start_dt))
_lbl(0, "Dokumenttyp:")
doc_combo = ttk.Combobox(
body, textvariable=doc_label_var, values=doc_labels,
state="readonly", font=(_FF, 9),
)
doc_combo.grid(row=0, column=1, sticky="ew", pady=(6, 2))
def _get_selected_doc_type() -> str:
lbl = doc_label_var.get()
for dt_key, dt_lbl in DOC_TYPES:
if dt_lbl == lbl:
return dt_key
return start_dt
_lbl(1, "Vorlagenname:")
title_var = _ent(1, iv.get("title") or default_title)
_lbl(2, "Verfasser:")
author_var = _ent(2, iv.get("author") or resolve_default_author_display(app))
_lbl(3, "Initialen:")
initials_var = _ent(3, iv.get("initials") or resolve_default_author_initials(app))
_lbl(4, "Ort:")
location_var = _ent(4, iv.get("location") or resolve_default_location(app))
_lbl(5, "Fachrichtung:")
specialty_var = _ent(5, iv.get("specialty") or resolve_default_specialty(app))
tk.Label(body, text="Beschreibung:", font=(_FF, 9, "bold"),
bg=_BG, fg=_TEXT, anchor="nw").grid(row=6, column=0, sticky="nw", pady=(10, 2))
desc_frame = tk.Frame(body, bg=_CARD_BG, highlightbackground=_CARD_BD, highlightthickness=1)
desc_frame.grid(row=6, column=1, sticky="nsew", pady=(10, 2))
body.rowconfigure(6, weight=1)
desc_vsb = tk.Scrollbar(desc_frame, orient="vertical")
desc_vsb.pack(side="right", fill="y")
desc_height = 7 if mode == "edit" else 9
desc_txt = tk.Text(desc_frame, font=(_FF, 9), bg="#FFFFFF", fg=_TEXT,
wrap="word", height=desc_height, yscrollcommand=desc_vsb.set,
padx=8, pady=8, relief="flat")
desc_txt.pack(side="left", fill="both", expand=True)
desc_vsb.config(command=desc_txt.yview)
desc_init = (iv.get("description") or "").strip()
if desc_init:
desc_txt.insert("1.0", desc_init)
dict_row = tk.Frame(body, bg=_BG)
dict_row.grid(row=7, column=1, sticky="w", pady=(4, 0))
dict_status = tk.StringVar(value="")
btn_dict = tk.Button(dict_row, text="⏺ Diktieren", font=(_FF, 9, "bold"),
bg=_BTN_BLUE, fg="#FFFFFF", relief="flat", bd=0,
padx=12, pady=4, cursor="hand2")
btn_dict.pack(side="left")
tk.Label(dict_row, textvariable=dict_status, font=(_FF, 8),
bg=_BG, fg=_TEXT_SUB).pack(side="left", padx=(8, 0))
_wire_text_dictation(app, dlg, desc_txt, dict_status, btn_dict)
if mode == "publish":
hint = tk.Label(
body,
text="Bitte prüfen: keine Patientendaten oder vertraulichen Praxisdaten.",
font=(_FF, 8), bg=_BG, fg=_TEXT_SUB, wraplength=480, justify="left",
)
hint.grid(row=8, column=0, columnspan=2, sticky="w", pady=(8, 0))
def _cancel():
release_modal_dialog(dlg, parent)
dlg.destroy()
def _confirm():
title = title_var.get().strip()
if not title:
aza_showinfo("Hinweis", "Bitte einen Vorlagennamen eingeben.", parent=dlg)
return
meta = {
"doc_type": _get_selected_doc_type(),
"title": title,
"description": desc_txt.get("1.0", "end-1c").strip(),
"author": author_var.get().strip(),
"initials": initials_var.get().strip(),
"location": location_var.get().strip(),
"specialty": specialty_var.get().strip(),
}
release_modal_dialog(dlg, parent)
dlg.destroy()
try:
on_confirm(meta)
except Exception:
pass
tk.Button(btn_row, text=confirm_label, command=_confirm,
font=(_FF, 9, "bold"), bg=_BTN_BLUE, fg="#FFFFFF",
relief="flat", bd=0, padx=14, pady=6, cursor="hand2").pack(side="left", padx=(16, 8))
tk.Button(btn_row, text="Abbrechen", command=_cancel,
font=(_FF, 9, "bold"), bg="#8AAFC8", fg="#FFFFFF",
relief="flat", bd=0, padx=14, pady=6, cursor="hand2").pack(side="left")
dlg.protocol("WM_DELETE_WINDOW", _cancel)
def open_public_doku_vorlagen_window(app: Any, parent: tk.Misc | None = None) -> None:
"""Öffentliche Vorlagen — separates Werkzeugfenster (lokal, kein Serverzwang)."""
existing = getattr(app, "_public_doku_vorlagen_win", None)
if existing is not None:
try:
if existing.winfo_exists():
existing.lift()
_doku_bring_to_front(existing)
return
except Exception:
pass
owner = parent or app
win = tk.Toplevel(owner)
app._public_doku_vorlagen_win = win
win.title("Öffentliche Vorlagen")
win.configure(bg=_BG)
win.resizable(True, True)
_apply_tool_window_geometry(
win, _PUBLIC_WIN_W, _PUBLIC_WIN_H, _PUBLIC_WIN_MIN_W, _PUBLIC_WIN_MIN_H, parent=owner,
)
if hasattr(app, "_register_window"):
try:
app._register_window(win)
except Exception:
pass
author = resolve_default_author_display(app)
initials = _author_initials(author)
location = resolve_default_location(app)
specialty = resolve_default_specialty(app)
hdr = tk.Frame(win, bg=_HDR_BG)
hdr.pack(fill="x")
tk.Label(hdr, text="Öffentliche Vorlagen", font=(_FF, 13, "bold"),
bg=_HDR_BG, fg=_HDR_FG, padx=18, pady=10).pack(side="left")
close_x = tk.Label(hdr, text="", font=(_FF, 11), bg=_HDR_BG, fg="#A0C4D8",
cursor="hand2", padx=14)
close_x.pack(side="right")
meta = tk.Frame(win, bg=_SUB_BG)
meta.pack(side="top", fill="x")
tk.Label(
meta,
text=(
f"Standard für Veröffentlichungen: Verfasser {author or initials} "
f"· Ort {location} · Fachrichtung {specialty}"
),
font=(_FF, 8), bg=_SUB_BG, fg=_TEXT_SUB, padx=18, pady=5, anchor="w",
).pack(fill="x")
action_bar = tk.Frame(win, bg="#E8F0F5", pady=8)
action_bar.pack(side="bottom", fill="x")
body = tk.Frame(win, bg=_BG)
body.pack(side="top", fill="both", expand=True, padx=12, pady=10)
body.rowconfigure(2, weight=3)
body.rowconfigure(3, weight=2)
body.columnconfigure(0, weight=1)
search_row = tk.Frame(body, bg=_BG)
search_row.grid(row=0, column=0, sticky="ew", pady=(0, 6))
search_row.columnconfigure(1, weight=1)
tk.Label(search_row, text="Suchen:", font=(_FF, 8, "bold"),
bg=_BG, fg=_TEXT).grid(row=0, column=0, sticky="w", padx=(0, 6))
search_var = tk.StringVar(value="")
search_entry = tk.Entry(search_row, textvariable=search_var, font=(_FF, 9),
bg="#FFFFFF", fg=_TEXT, relief="flat",
highlightbackground=_CARD_BD, highlightthickness=1)
search_entry.grid(row=0, column=1, sticky="ew", ipady=3)
status_var = tk.StringVar(value="Lade öffentliche Vorlagen …")
tk.Label(body, textvariable=status_var, font=(_FF, 8),
bg=_BG, fg=_TEXT_SUB, anchor="w").grid(row=1, column=0, sticky="ew", pady=(0, 6))
table_frame = tk.Frame(body, bg=_CARD_BG,
highlightbackground=_CARD_BD, highlightthickness=1)
table_frame.grid(row=2, column=0, sticky="nsew")
table_frame.rowconfigure(0, weight=1)
table_frame.columnconfigure(0, weight=1)
pub_style = ttk.Style(win)
pub_style.configure("Public.Treeview", font=(_FF, 8), rowheight=26)
pub_style.configure("Public.Treeview.Heading", font=(_FF, 8, "bold"))
cols = ("doc_type", "title", "rating", "author", "location", "specialty", "status")
tree = ttk.Treeview(
table_frame, columns=cols, show="headings", selectmode="browse",
height=12, style="Public.Treeview",
)
headings = {
"doc_type": "Dokumenttyp",
"title": "Vorlagenname",
"rating": "Bewertung",
"author": "Verfasser",
"location": "Ort",
"specialty": "Fachrichtung",
"status": "Status",
}
widths = {
"doc_type": 100, "title": 170, "rating": 150, "author": 120,
"location": 80, "specialty": 110, "status": 120,
}
for col in cols:
tree.heading(col, text=headings[col])
tree.column(col, width=widths[col], minwidth=60, stretch=(col == "title"))
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew", padx=6, pady=6)
vsb.grid(row=0, column=1, sticky="ns", pady=6)
detail_outer = tk.Frame(body, bg=_SUB_BG, highlightbackground=_CARD_BD, highlightthickness=1)
detail_outer.grid(row=3, column=0, sticky="nsew", pady=(8, 0))
detail_outer.rowconfigure(0, weight=1)
detail_outer.columnconfigure(0, weight=1)
detail_vsb = tk.Scrollbar(detail_outer, orient="vertical")
detail_vsb.grid(row=0, column=1, sticky="ns")
detail_canvas = tk.Canvas(detail_outer, bg=_SUB_BG, highlightthickness=0,
yscrollcommand=detail_vsb.set)
detail_canvas.grid(row=0, column=0, sticky="nsew")
detail_vsb.config(command=detail_canvas.yview)
detail_frame = tk.Frame(detail_canvas, bg=_SUB_BG)
detail_canvas_window = detail_canvas.create_window((0, 0), window=detail_frame, anchor="nw")
def _detail_scroll_update(_evt=None):
detail_canvas.configure(scrollregion=detail_canvas.bbox("all"))
try:
detail_canvas.itemconfig(detail_canvas_window, width=detail_canvas.winfo_width())
except Exception:
pass
detail_frame.bind("<Configure>", _detail_scroll_update)
detail_canvas.bind("<Configure>", _detail_scroll_update)
detail_vars: dict[str, tk.StringVar] = {}
detail_rows = [
("doc_type", "Dokumenttyp"),
("title", "Vorlagenname"),
("author", "Verfasser"),
("location", "Ort"),
("specialty", "Fachrichtung"),
]
for r, (key, label) in enumerate(detail_rows):
tk.Label(detail_frame, text=f"{label}:", font=(_FF, 8, "bold"),
bg=_SUB_BG, fg=_TEXT_SUB, anchor="w").grid(
row=r, column=0, sticky="w", padx=(10, 6), pady=2,
)
detail_vars[key] = tk.StringVar(value="")
tk.Label(detail_frame, textvariable=detail_vars[key], font=(_FF, 8),
bg=_SUB_BG, fg=_TEXT, anchor="w").grid(
row=r, column=1, sticky="w", pady=2, padx=(0, 10),
)
desc_row = len(detail_rows)
tk.Label(detail_frame, text="Beschreibung:", font=(_FF, 8, "bold"),
bg=_SUB_BG, fg=_TEXT_SUB, anchor="nw").grid(
row=desc_row, column=0, sticky="nw", padx=(10, 6), pady=(4, 2),
)
detail_desc = tk.Text(
detail_frame, font=(_FF, 8), bg=_SUB_BG, fg=_TEXT,
wrap="word", height=3, relief="flat", bd=0, padx=0, pady=2,
)
detail_desc.grid(row=desc_row, column=1, sticky="ew", pady=(4, 2), padx=(0, 10))
detail_desc.config(state="disabled")
prompt_row = desc_row + 1
tk.Label(detail_frame, text="Prompt:", font=(_FF, 8, "bold"),
bg=_SUB_BG, fg=_TEXT_SUB, anchor="nw").grid(
row=prompt_row, column=0, sticky="nw", padx=(10, 6), pady=(4, 8),
)
detail_prompt = tk.Text(
detail_frame, font=(_FF, 8), bg=_SUB_BG, fg=_TEXT,
wrap="word", height=6, relief="flat", bd=0, padx=0, pady=2,
)
detail_prompt.grid(row=prompt_row, column=1, sticky="ew", pady=(4, 8), padx=(0, 10))
detail_prompt.config(state="disabled")
detail_frame.columnconfigure(1, weight=1)
rating_row = prompt_row + 1
tk.Label(detail_frame, text="Bewertung:", font=(_FF, 8, "bold"),
bg=_SUB_BG, fg=_TEXT_SUB, anchor="nw").grid(
row=rating_row, column=0, sticky="nw", padx=(10, 6), pady=(4, 8),
)
rating_block = tk.Frame(detail_frame, bg=_SUB_BG)
rating_block.grid(row=rating_row, column=1, sticky="ew", pady=(4, 8), padx=(0, 10))
rating_summary_var = tk.StringVar(value="Noch keine Bewertungen · 0 Bewertungen")
tk.Label(
rating_block, textvariable=rating_summary_var, font=(_FF, 8),
bg=_SUB_BG, fg=_TEXT, anchor="w", justify="left",
).pack(anchor="w")
rating_own_hint_var = tk.StringVar(value="")
tk.Label(
rating_block, textvariable=rating_own_hint_var, font=(_FF, 8),
bg=_SUB_BG, fg=_TEXT_SUB, anchor="w", justify="left",
).pack(anchor="w", pady=(2, 4))
rating_user_fr = tk.Frame(rating_block, bg=_SUB_BG)
rating_user_fr.pack(anchor="w", fill="x")
tk.Label(
rating_user_fr, text="Ihre Bewertung:", font=(_FF, 8, "bold"),
bg=_SUB_BG, fg=_TEXT_SUB,
).pack(side="left", padx=(0, 8))
rating_inflight = {"busy": False}
def _submit_rating(val: float) -> None:
item = _selected_item()
if not item or rating_inflight["busy"]:
return
pid = str(item.get("server_id") or item.get("id") or "").strip()
if not pid:
aza_showwarning("Hinweis", "Diese Vorlage kann noch nicht bewertet werden.", parent=win)
return
rating_inflight["busy"] = True
try:
from aza_doku_prompt_sync import rate_public_doku_prompt_result
ok, msg, payload = rate_public_doku_prompt_result(app, pid, val)
except Exception as exc: # noqa: BLE001
aza_showerror("Fehler", f"Bewertung fehlgeschlagen:\n{exc}", parent=win)
rating_inflight["busy"] = False
return
rating_inflight["busy"] = False
if not ok:
if msg == "__ROUTE_MISSING__":
aza_showinfo(
"Noch nicht live",
"Die Bewertungsfunktion ist auf dem Server noch nicht deployt.",
parent=win,
)
elif msg == "__CONN_ERROR__":
aza_showwarning(
"Server nicht erreichbar",
"Bitte später erneut versuchen.",
parent=win,
)
else:
aza_showwarning("Fehler", msg or "Bewertung nicht möglich.", parent=win)
return
_apply_rating_fields_to_item(item, payload)
for src in all_source_items:
if str(src.get("id") or src.get("server_id") or "") == pid:
_apply_rating_fields_to_item(src, payload)
_apply_search()
sel = tree.selection()
if sel:
_show_detail(tree_iid_to_item.get(sel[0]))
aza_showinfo("Gespeichert", "Bewertung wurde gespeichert.", parent=win)
rating_bar = build_half_star_rating_bar(
rating_user_fr, bg=_SUB_BG, on_save=_submit_rating,
)
rating_bar["frame"].pack(side="left")
tk.Label(
rating_user_fr, textvariable=rating_bar["value_var"], font=(_FF, 8),
bg=_SUB_BG, fg=_TEXT_SUB,
).pack(side="left", padx=(8, 0))
all_source_items: list[dict[str, Any]] = []
library_items: list[dict[str, Any]] = []
tree_iid_to_item: dict[str, dict[str, Any]] = {}
def _close():
try:
app._public_doku_vorlagen_win = None
except Exception:
pass
try:
win.destroy()
except Exception:
pass
def _selected_item() -> dict[str, Any] | None:
sel = tree.selection()
if not sel:
return None
return tree_iid_to_item.get(sel[0])
def _show_detail(item: dict[str, Any] | None) -> None:
if not item:
for v in detail_vars.values():
v.set("")
detail_desc.config(state="normal")
detail_desc.delete("1.0", "end")
detail_desc.config(state="disabled")
detail_prompt.config(state="normal")
detail_prompt.delete("1.0", "end")
detail_prompt.config(state="disabled")
rating_summary_var.set("Noch keine Bewertungen · 0 Bewertungen")
rating_own_hint_var.set("")
rating_bar["set_value"](0)
rating_bar["set_enabled"](False)
_detail_scroll_update()
return
dt_lbl = doc_type_label(item.get("doc_type") or "")
detail_vars["doc_type"].set(dt_lbl)
detail_vars["title"].set(item.get("title") or "Ohne Titel")
detail_vars["author"].set(item.get("author_display") or item.get("author") or "")
detail_vars["location"].set(item.get("location") or item.get("city") or location)
detail_vars["specialty"].set(item.get("specialty") or specialty)
desc = (item.get("description") or "").strip() or ""
detail_desc.config(state="normal")
detail_desc.delete("1.0", "end")
detail_desc.insert("1.0", desc)
detail_desc.config(state="disabled")
prompt = (item.get("content") or "").strip() or ""
detail_prompt.config(state="normal")
detail_prompt.delete("1.0", "end")
detail_prompt.insert("1.0", prompt)
detail_prompt.config(state="disabled")
avg = item.get("rating_average")
cnt = int(item.get("rating_count") or 0)
rating_summary_var.set(format_public_rating_summary(avg, cnt))
own = is_own_public_item(app, item)
can_rate = bool(item.get("can_rate")) and not own and not item.get("_local_prepared")
if own:
rating_own_hint_var.set("Die eigene Vorlage kann nicht bewertet werden.")
rating_bar["set_enabled"](False)
rating_bar["set_value"](item.get("my_rating") or 0)
elif can_rate:
rating_own_hint_var.set("")
rating_bar["set_enabled"](True)
rating_bar["set_value"](item.get("my_rating") or 0)
else:
rating_own_hint_var.set("")
rating_bar["set_enabled"](False)
rating_bar["set_value"](item.get("my_rating") or 0)
_detail_scroll_update()
def _update_btn_state(_evt=None):
item = _selected_item()
has_sel = item is not None
own = is_own_public_item(app, item) if item else False
from aza_ui_helpers import set_tool_action_button_enabled
set_tool_action_button_enabled(btn_adopt, has_sel)
set_tool_action_button_enabled(btn_edit, own)
set_tool_action_button_enabled(btn_delete, own)
def _render_tree(items: list[dict[str, Any]], *, status_note: str = "") -> None:
for row in tree.get_children():
tree.delete(row)
library_items.clear()
tree_iid_to_item.clear()
for item in items:
if not isinstance(item, dict):
continue
dt = item.get("doc_type") or ""
library_items.append(item)
own = is_own_public_item(app, item)
iid = tree.insert(
"", "end",
values=(
doc_type_label(dt),
item.get("title") or "Ohne Titel",
format_public_rating_list_text(
item.get("rating_average"),
int(item.get("rating_count") or 0),
),
item.get("author_display") or item.get("author") or "",
item.get("location") or item.get("city") or location,
item.get("specialty") or specialty,
"Eigene Veröffentlichung" if own else "",
),
)
tree_iid_to_item[iid] = item
if status_note:
status_var.set(status_note)
_show_detail(None)
_update_btn_state()
def _apply_search(*_evt) -> None:
filtered = filter_public_items(all_source_items, search_var.get())
total = len(all_source_items)
shown = len(filtered)
if total and shown != total:
note = f"{shown} von {total} Vorlage(n) (gefiltert)."
elif total:
note = f"{shown} öffentliche Vorlage(n)."
else:
note = status_var.get()
_render_tree(filtered, status_note=note)
def _set_source_items(items: list[dict[str, Any]]) -> None:
all_source_items.clear()
all_source_items.extend(items)
_apply_search()
def _populate(items: list[dict[str, Any]]) -> None:
merged = list(items) if items else []
if not merged:
local = load_local_prepared_public_items()
if local:
merged = local
_set_source_items(local)
status_var.set(
f"{len(local)} lokal vorbereitete Vorlage(n) "
f"(Live-Bibliothek benötigt noch Server-Deploy)."
)
else:
status_var.set(_PUBLIC_LIB_PLACEHOLDER)
_set_source_items([])
return
if any(it.get("_local_prepared") for it in merged):
status_var.set(f"{len(merged)} Vorlage(n) (teilweise lokal vorbereitet).")
else:
status_var.set(f"{len(merged)} öffentliche Vorlage(n) geladen.")
_set_source_items(merged)
def _adopt_selected():
item = _selected_item()
if not item:
aza_showinfo("Hinweis", "Bitte eine Vorlage auswählen.", parent=win)
return
content = (item.get("content") or "").strip()
if not content:
aza_showwarning(
"Hinweis",
"Diese Vorlage enthält keinen Prompttext und kann nicht übernommen werden.",
parent=win,
)
return
dt = (item.get("doc_type") or "kg").strip()
if dt not in DOC_TYPE_KEYS:
aza_showwarning(
"Hinweis",
f"Unbekannter Dokumenttyp '{dt}'. Übernahme nicht möglich.",
parent=win,
)
return
try:
new_id = adopt_public_template_to_own(app, item, activate=True)
except Exception as exc: # noqa: BLE001
aza_showerror(
"Fehler",
f"Übernahme fehlgeschlagen:\n{exc}",
parent=win,
)
return
if new_id:
dt_lbl = doc_type_label(dt)
aza_showinfo(
"Übernommen",
f"Vorlage wurde als eigene {dt_lbl}-Vorlage übernommen.\n"
"Sie erscheint im Doku-Prompt-Dropdown.",
parent=win,
)
else:
aza_showwarning(
"Fehler",
"Vorlage konnte nicht als eigene Vorlage gespeichert werden.",
parent=win,
)
def _delete_selected():
item = _selected_item()
if not item or not is_own_public_item(app, item):
return
qidx = item.get("_queue_index")
server_id = str(item.get("server_id") or item.get("id") or "").strip()
if qidx is None and server_id:
title = item.get("title") or "Ohne Titel"
if not aza_askyesno(
"Veröffentlichung zurückziehen?",
f"Veröffentlichung '{title}' vom Server entfernen?\n\n"
"Ihre private Vorlage (falls übernommen) bleibt erhalten.",
parent=win,
):
return
try:
from aza_doku_prompt_sync import unpublish_doku_prompt
ok, msg = unpublish_doku_prompt(app, server_id)
except Exception as exc: # noqa: BLE001
aza_showerror("Fehler", f"Entfernen fehlgeschlagen:\n{exc}", parent=win)
return
if ok:
aza_showinfo("Entfernt", "Veröffentlichung wurde zurückgezogen.", parent=win)
_load_async()
elif msg == "__ROUTE_MISSING__":
aza_showinfo(
"Noch nicht live",
"Die Server-Funktion zum Zurückziehen ist noch nicht deployt.\n"
"Lokal vorbereitete Veröffentlichungen können weiterhin entfernt werden.",
parent=win,
)
elif msg == "__CONN_ERROR__":
aza_showwarning(
"Server nicht erreichbar",
"Der Server ist derzeit nicht erreichbar. Bitte später erneut versuchen.",
parent=win,
)
else:
aza_showwarning("Fehler", msg or "Veröffentlichung konnte nicht entfernt werden.", parent=win)
return
if qidx is None:
aza_showinfo(
"Hinweis",
"Nur lokal vorbereitete eigene Veröffentlichungen können hier gelöscht werden.",
parent=win,
)
return
title = item.get("title") or "Ohne Titel"
if not aza_askyesno(
"Löschen?",
f"Veröffentlichung '{title}' aus der lokalen Queue entfernen?\n\n"
"Ihre private Vorlage (falls übernommen) bleibt erhalten.",
parent=win,
):
return
if remove_local_publish_queue_item(int(qidx)):
refreshed = load_local_prepared_public_items()
_set_source_items(refreshed)
status_var.set(f"{len(refreshed)} lokal vorbereitete Vorlage(n).")
aza_showinfo("Gelöscht", "Veröffentlichung wurde entfernt.", parent=win)
else:
aza_showwarning("Fehler", "Veröffentlichung konnte nicht gelöscht werden.", parent=win)
def _edit_selected():
item = _selected_item()
if not item or not is_own_public_item(app, item):
return
qidx = item.get("_queue_index")
server_id = str(item.get("server_id") or item.get("id") or "").strip()
def _on_edit_confirm(meta: dict[str, Any]):
pub_dt = meta.get("doc_type") or item.get("doc_type") or "kg"
updates = {
"document_type_key": pub_dt,
"document_type_label": doc_type_label(pub_dt),
"title": meta.get("title") or "",
"description": meta.get("description") or "",
"author_display_name": meta.get("author") or "",
"author_initials": meta.get("initials") or "",
"specialty": meta.get("specialty") or "",
"region": meta.get("location") or "",
}
if qidx is not None and update_local_publish_queue_item(int(qidx), updates):
refreshed = load_local_prepared_public_items()
_set_source_items(refreshed)
aza_showinfo("Gespeichert", "Metadaten aktualisiert.", parent=win)
return
if server_id and qidx is None:
tpl = {
"content": item.get("content") or "",
"server_id": server_id,
"specialty": meta.get("specialty") or item.get("specialty") or "",
"city": meta.get("location") or item.get("city") or "",
}
ok, msg = publish_template_to_server(
app, pub_dt, tpl,
meta.get("title") or item.get("title") or "",
meta.get("description") or item.get("description") or "",
)
if ok:
aza_showinfo("Gespeichert", "Veröffentlichung auf dem Server aktualisiert.", parent=win)
_load_async()
elif msg == "__ROUTE_MISSING__":
aza_showinfo(
"Noch nicht live",
"Die Server-Funktion zum Bearbeiten ist noch nicht deployt.",
parent=win,
)
elif msg == "__CONN_ERROR__":
aza_showwarning(
"Server nicht erreichbar",
"Der Server ist derzeit nicht erreichbar. Bitte später erneut versuchen.",
parent=win,
)
else:
aza_showwarning("Fehler", msg or "Metadaten konnten nicht gespeichert werden.", parent=win)
return
aza_showwarning("Fehler", "Metadaten konnten nicht gespeichert werden.", parent=win)
open_doku_publish_dialog(
app, win,
doc_type=item.get("doc_type") or "kg",
default_title=item.get("title") or "Ohne Titel",
on_confirm=_on_edit_confirm,
mode="edit",
initial_values={
"doc_type": item.get("doc_type") or "kg",
"title": item.get("title") or "",
"author": item.get("author_display") or item.get("author") or "",
"initials": item.get("author_initials") or "",
"location": item.get("location") or item.get("city") or "",
"specialty": item.get("specialty") or "",
"description": item.get("description") or "",
},
confirm_label="Speichern",
window_title="Metadaten bearbeiten",
)
def _on_tree_select(_evt=None):
_show_detail(_selected_item())
_update_btn_state()
def _on_double_click(_evt=None):
item = _selected_item()
if item and is_own_public_item(app, item):
_edit_selected()
tree.bind("<<TreeviewSelect>>", _on_tree_select)
tree.bind("<Double-Button-1>", _on_double_click)
search_var.trace_add("write", lambda *_: _apply_search())
def _load_async():
import threading
def _worker():
try:
items, err = fetch_public_prompt_library_result(app)
except Exception:
items, err = [], "__CONN_ERROR__"
if not items:
local = load_local_prepared_public_items()
if local:
note = (
f"{len(local)} lokal vorbereitete Vorlage(n) "
f"(Live-Bibliothek benötigt noch Server-Deploy)."
)
if err == "__CONN_ERROR__":
note = (
f"{len(local)} lokal vorbereitete Vorlage(n) "
f"(Server nicht erreichbar — lokaler Fallback)."
)
try:
app.after(0, lambda: _populate_local_fallback(local, note))
except Exception:
pass
return
if err == "__ROUTE_MISSING__":
note = _PUBLIC_LIB_PLACEHOLDER
elif err == "__CONN_ERROR__":
note = (
"Server nicht erreichbar. "
"Lokal vorbereitete Vorlagen werden angezeigt, sobald vorhanden."
)
else:
note = _PUBLIC_LIB_PLACEHOLDER
try:
app.after(0, lambda: _populate_empty(note))
except Exception:
pass
return
try:
app.after(0, lambda: _populate(items))
except Exception:
pass
def _populate_local_fallback(local_items, note):
_populate(local_items)
status_var.set(note)
def _populate_empty(note):
status_var.set(note)
_set_source_items([])
threading.Thread(target=_worker, daemon=True).start()
from aza_ui_helpers import create_tool_action_button, set_tool_action_button_enabled
btn_adopt = create_tool_action_button(
action_bar, "In eigene Vorlage übernehmen", _adopt_selected, kind="primary",
)
btn_adopt.pack(side="left", padx=(12, 4))
set_tool_action_button_enabled(btn_adopt, False)
btn_edit = create_tool_action_button(
action_bar, "Bearbeiten", _edit_selected, kind="secondary",
)
btn_edit.pack(side="left", padx=4)
set_tool_action_button_enabled(btn_edit, False)
btn_delete = create_tool_action_button(
action_bar, "Löschen", _delete_selected, kind="danger",
)
btn_delete.pack(side="left", padx=4)
set_tool_action_button_enabled(btn_delete, False)
create_tool_action_button(
action_bar, "Schliessen", _close, kind="close", width=10,
).pack(side="right", padx=(4, 12))
close_x.bind("<Button-1>", lambda e: _close())
win.protocol("WM_DELETE_WINDOW", _close)
_load_async()
# ── Fenster ───────────────────────────────────────────────────────────────────
def open_doku_vorlage_fenster(app: Any) -> None:
"""Oeffnet das Doku-Prompt-Fenster. Singleton."""
existing = getattr(app, "_doku_vorlage_win", None)
if existing is not None:
try:
if existing.winfo_exists():
existing.lift(); existing.focus_force(); return
except Exception:
pass
win = tk.Toplevel(app)
app._doku_vorlage_win = win
win.title("Doku-Prompt")
win.configure(bg=_BG)
win.resizable(True, True)
win.minsize(_DOKU_WIN_MIN_W, _DOKU_WIN_MIN_H)
_doku_center_top(win, _DOKU_WIN_W, _DOKU_WIN_H, parent=app)
if hasattr(app, "_register_window"):
try: app._register_window(win)
except Exception: pass
try:
import threading
threading.Thread(target=lambda: sync_private_doku_prompts(app), daemon=True).start()
except Exception:
pass
# ── Header ────────────────────────────────────────────────────────────────
hdr = tk.Frame(win, bg=_HDR_BG)
hdr.pack(fill="x")
tk.Label(hdr, text="📄 Doku-Prompt", font=(_FF, 13, "bold"),
bg=_HDR_BG, fg=_HDR_FG, padx=18, pady=10).pack(side="left")
close_x = tk.Label(hdr, text="", font=(_FF, 11), bg=_HDR_BG, fg="#A0C4D8",
cursor="hand2", padx=14)
close_x.pack(side="right")
sub = tk.Frame(win, bg=_SUB_BG)
sub.pack(fill="x")
tk.Label(sub,
text="Prompts für medizinische Dokumente anpassen. "
"Diagnose-/Therapie-Gliederung wird über das Häkchen 'Diagnosen gliedern' gesteuert.",
font=(_FF, 8), bg=_SUB_BG, fg=_TEXT_SUB,
padx=18, pady=5, wraplength=780, justify="left").pack(anchor="w")
# ── Hauptbereich ──────────────────────────────────────────────────────────
body = tk.Frame(win, bg=_BG)
body.pack(fill="both", expand=True, padx=10, pady=8)
body.columnconfigure(1, weight=1)
body.rowconfigure(0, weight=1)
# Linke Spalte — Dokumenttypen
left = tk.Frame(body, bg=_CARD_BG, bd=0,
highlightbackground=_CARD_BD, highlightthickness=1, width=140)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
left.grid_propagate(False)
tk.Label(left, text="Dokumenttyp", font=(_FF, 9, "bold"),
bg="#E0EEF7", fg=_TEXT, pady=6).pack(fill="x")
# Rechte Karte — Bearbeitungsfläche
right = tk.Frame(body, bg=_CARD_BG, bd=0,
highlightbackground=_CARD_BD, highlightthickness=1)
right.grid(row=0, column=1, sticky="nsew")
right.rowconfigure(2, weight=1)
right.columnconfigure(0, weight=1)
# ── State-Variablen ───────────────────────────────────────────────────────
_data: list[dict] = [_load()]
_cur_dt: list[str] = ["kg"]
_cur_tpl: list[dict] = [{}]
_dirty: list[bool] = [False]
combo_var = [None]
combo_widget = [None]
txt_content = [None]
lbl_doctype = [None]
lbl_active_info = [None]
lbl_mode = [None]
btn_save = [None]
btn_activate = [None]
def _tpls(dt): return _data[0].get("templates", {}).get(dt, [])
def _aid(dt): return _data[0].get("active", {}).get(dt, "aza_default")
def _by_id(dt, tid):
for t in _tpls(dt):
if isinstance(t, dict) and t.get("id") == tid: return t
return None
def _dname(t):
n = (t.get("name") or "Unbenannt").strip()
return f"{n}" if t.get("is_system") else n
def _refresh(dt: str, tpl_id: str | None = None):
templates = _tpls(dt)
active_id = _aid(dt)
target_id = tpl_id or active_id
tpl = _by_id(dt, target_id)
if tpl is None and templates:
tpl = templates[0]
if tpl is None:
tpl = {"id": "aza_default", "name": "AzA-Grundvorlage",
"is_system": True, "content": AZA_DEFAULT_TEMPLATES.get(dt, "")}
_cur_tpl[0] = tpl
# Combo
names = [_dname(t) for t in templates if isinstance(t, dict)]
if combo_widget[0]:
combo_widget[0]["values"] = names
combo_var[0].set(_dname(tpl))
# Label Dokumenttyp
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
if lbl_doctype[0]:
lbl_doctype[0].config(text=dlabel)
# Textfeld — IMMER beschreibbar, unabhaengig von Systemvorlage
if txt_content[0]:
txt_content[0].config(state="normal")
txt_content[0].delete("1.0", "end")
txt_content[0].insert("1.0", tpl.get("content") or "")
txt_content[0].config(state="normal", bg="#FFFFFF", fg=_TEXT)
# Aktive-Info
at = _by_id(dt, active_id)
aname = _dname(at) if at else active_id
if lbl_active_info[0]:
lbl_active_info[0].config(text=f"Aktive Vorlage: {aname}")
is_system = bool(tpl.get("is_system"))
is_active = (tpl.get("id") == active_id)
# Modus-Hinweis
if lbl_mode[0]:
if is_system:
lbl_mode[0].config(
text="Systemvorlage 'Speichern' nicht direkt möglich → 'Als neue Vorlage speichern'",
fg="#B06000")
else:
lbl_mode[0].config(
text="Benutzervorlage direkt bearbeitbar und speicherbar.",
fg="#2A7A4A")
# Buttons
if btn_activate[0]:
btn_activate[0].config(
text="Aktiv" if is_active else "Als aktiv setzen",
bg="#8AAFC8" if is_active else _BTN_ACT,
state="disabled" if is_active else "normal",
)
if btn_save[0]:
# Speichern bei Systemvorlage deaktivieren — klare Führung
btn_save[0].config(
state="disabled" if is_system else "normal",
bg="#8AAFC8" if is_system else _BTN_BLUE,
)
_dirty[0] = False
def _on_combo(evt=None):
dt = _cur_dt[0]
sel = combo_var[0].get() if combo_var[0] else ""
for t in _tpls(dt):
if isinstance(t, dict) and _dname(t) == sel:
_refresh(dt, t.get("id")); return
def _do_save():
tpl = _cur_tpl[0]
if tpl.get("is_system"):
aza_showinfo(
"Systemvorlage",
"Die AzA-Grundvorlage kann nicht direkt überschrieben werden.\n\n"
"Bitte verwenden Sie 'Als neue Vorlage speichern', um eine\n"
"eigene Variante zu erstellen.",
parent=win)
return
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
tpl["content"] = content
tpl["updated_at"] = _utc_now()
tpl["revision"] = int(tpl.get("revision") or 0) + 1
try:
_save(_data[0])
except Exception as e:
aza_showerror("Fehler", str(e), parent=win); return
_dirty[0] = False
aza_showinfo("Gespeichert", "Vorlage gespeichert.", parent=win)
def _do_save_new():
dt = _cur_dt[0]
name = aza_askstring("Neue Vorlage", "Name der neuen Vorlage:", parent=win)
if not name or not name.strip(): return
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
new_id = f"user_{int(time.time())}_{dt}"
uid, pid = _app_user_practice_ids(app)
new_tpl = {
"id": new_id, "name": name.strip(), "is_system": False,
"content": content, "description": "",
"created_at": _utc_now(), "updated_at": _utc_now(),
"revision": 1, "user_id": uid, "practice_id": pid,
"server_id": "", "published": False, "sync_updated_at": "",
"source_author": "", "source_server_id": "",
}
_data[0].setdefault("templates", {}).setdefault(dt, []).append(new_tpl)
activate = aza_askyesno("Aktivieren?",
f"Neue Vorlage '{name.strip()}' gespeichert.\nJetzt aktivieren?", parent=win)
if activate:
_data[0].setdefault("active", {})[dt] = new_id
try:
_save(_data[0])
except Exception as e:
aza_showerror("Fehler", str(e), parent=win); return
_refresh(dt, new_id if activate else _aid(dt))
def _do_activate():
dt = _cur_dt[0]; tpl = _cur_tpl[0]; tid = tpl.get("id", "")
if not tid: return
_data[0].setdefault("active", {})[dt] = tid
try: _save(_data[0])
except Exception as e:
aza_showerror("Fehler", str(e), parent=win); return
_refresh(dt, tid)
def _do_reset():
dt = _cur_dt[0]
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
if not aza_askyesno(
"Zurücksetzen?",
f"'{dlabel}' auf AzA-Grundvorlage zurücksetzen?\n\n"
"Die aktive Vorlage wird auf AzA-Grundvorlage gesetzt.\n"
"Ihre eigenen Varianten werden NICHT gelöscht.", parent=win):
return
templates = _tpls(dt)
has_default = any(isinstance(t, dict) and t.get("id") == "aza_default"
for t in templates)
if not has_default:
now = _utc_now()
_data[0].setdefault("templates", {}).setdefault(dt, []).insert(0, {
"id": "aza_default", "name": "AzA-Grundvorlage", "is_system": True,
"content": AZA_DEFAULT_TEMPLATES.get(dt, ""),
"created_at": now, "updated_at": now})
_data[0].setdefault("active", {})[dt] = "aza_default"
try: _save(_data[0])
except Exception as e:
aza_showerror("Fehler", str(e), parent=win); return
_refresh(dt, "aza_default")
def _do_delete():
dt = _cur_dt[0]
tpl = _cur_tpl[0]
if tpl.get("is_system"):
aza_showinfo("Systemvorlage", "AzA-Grundvorlage kann nicht gelöscht werden.", parent=win)
return
tid = tpl.get("id", "")
if not tid:
return
name = (tpl.get("name") or "Unbenannt").strip()
if not aza_askyesno("Löschen?", f"Vorlage '{name}' wirklich löschen?", parent=win):
return
templates = _tpls(dt)
_data[0]["templates"][dt] = [t for t in templates if not (isinstance(t, dict) and t.get("id") == tid)]
if _aid(dt) == tid:
_data[0].setdefault("active", {})[dt] = "aza_default"
try:
_save(_data[0])
except Exception as e:
aza_showerror("Fehler", str(e), parent=win)
return
_refresh(dt, _aid(dt))
def _do_publish():
dt = _cur_dt[0]
tpl = dict(_cur_tpl[0])
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
if not content:
aza_showinfo("Hinweis", "Bitte zuerst einen Prompt-Inhalt eingeben.", parent=win)
return
tpl["content"] = content
default_title = (tpl.get("name") or doc_type_label(dt)).strip()
def _publish_done(ok: bool, msg: str):
try:
app.set_status("Bereit.")
except Exception:
pass
if ok:
tpl["published"] = True
tpl["updated_at"] = _utc_now()
_save(_data[0])
aza_showinfo(
"Veröffentlicht",
"Prompt wurde für andere angemeldete Ärzte veröffentlicht.",
parent=win,
)
return
if msg == "__ROUTE_MISSING__" or (msg or "").strip().lower() in ("not found", "notfound"):
aza_showinfo("Veröffentlichung vorbereitet", _PUBLISH_ROUTE_MSG, parent=win)
return
if msg == "__CONN_ERROR__":
aza_showwarning(
"Server nicht erreichbar",
"Der Server ist derzeit nicht erreichbar.\n\n"
"Die Veröffentlichung wurde lokal vorbereitet und geht nicht verloren. "
"Bitte später erneut versuchen.",
parent=win,
)
return
aza_showwarning(
"Veröffentlichung",
(msg or "Veröffentlichung nicht möglich.") +
"\n\n(Die Vorlage wurde lokal vorbereitet.)",
parent=win,
)
def _on_publish_confirm(meta: dict[str, Any]):
title = meta.get("title") or ""
desc = meta.get("description") or ""
author_display = meta.get("author") or ""
pub_dt = meta.get("doc_type") or dt
uid, _ = _app_user_practice_ids(app)
try:
save_local_publish_payload({
"document_type_key": pub_dt,
"document_type_label": doc_type_label(pub_dt),
"title": title,
"description": desc,
"prompt_text": content,
"author_display_name": author_display,
"author_initials": meta.get("initials") or "",
"author_user_id": uid,
"specialty": meta.get("specialty") or "",
"language": "de",
"region": meta.get("location") or "",
"status": "prepared",
"created_at": _utc_now(),
"revision": int(tpl.get("revision") or 1),
"source_template_id": tpl.get("id") or "",
})
except Exception:
pass
app.set_status("Veröffentliche Prompt …")
def _worker():
try:
ok, msg = publish_template_to_server(app, pub_dt, tpl, title, desc)
except Exception as e: # noqa: BLE001
ok, msg = False, str(e)
app.after(0, lambda: _publish_done(ok, msg))
import threading
threading.Thread(target=_worker, daemon=True).start()
open_doku_publish_dialog(
app, win, doc_type=dt, default_title=default_title, on_confirm=_on_publish_confirm,
)
def _external_refresh(dt=None, tpl_id=None):
_data[0] = _load()
target_dt = dt or _cur_dt[0]
if target_dt != _cur_dt[0]:
_sel_dt(target_dt)
else:
_refresh(target_dt, tpl_id or _aid(target_dt))
try:
app._doku_vorlage_refresh_cb = _external_refresh
except Exception:
pass
def _close():
try: app._doku_vorlage_win = None
except Exception: pass
try: app._doku_vorlage_refresh_cb = None
except Exception: pass
try: win.destroy()
except Exception: pass
close_x.bind("<Button-1>", lambda e: _close())
win.protocol("WM_DELETE_WINDOW", _close)
# ── Linke Buttons (Dokumenttypen) ─────────────────────────────────────────
_dt_btns: dict[str, tk.Label] = {}
def _sel_dt(dt: str):
_cur_dt[0] = dt
for k, b in _dt_btns.items():
b.config(bg=_ACCENT if k == dt else _CARD_BG,
fg="#FFFFFF" if k == dt else _TEXT)
_refresh(dt)
for dt_key, dt_label in DOC_TYPES:
b = tk.Label(left, text=dt_label, font=(_FF, 9),
bg=_CARD_BG, fg=_TEXT, cursor="hand2",
pady=9, anchor="w", padx=12)
b.pack(fill="x")
b.bind("<Button-1>", lambda e, dk=dt_key: _sel_dt(dk))
_dt_btns[dt_key] = b
tk.Frame(left, bg=_CARD_BD, height=1).pack(fill="x", padx=10, pady=(10, 6))
btn_public_left = tk.Button(
left, text="Öffentliche Vorlagen",
command=lambda: open_public_doku_vorlagen_window(app, parent=win),
font=(_FF, 8, "bold"), bg="#3A6F8F", fg="#FFFFFF",
relief="flat", bd=0, padx=8, pady=8, cursor="hand2",
activebackground="#2E5F7A",
)
btn_public_left.pack(fill="x", padx=10, pady=(0, 10))
# ── Rechte Karte ──────────────────────────────────────────────────────────
# Zeile 0: Header (Dokumenttyp + aktive Vorlage)
top_bar = tk.Frame(right, bg="#E0EEF7")
top_bar.grid(row=0, column=0, sticky="ew")
lbl_doctype[0] = tk.Label(top_bar, text="Krankengeschichte", font=(_FF, 10, "bold"),
bg="#E0EEF7", fg=_TEXT, padx=12, pady=6)
lbl_doctype[0].pack(side="left")
lbl_active_info[0] = tk.Label(top_bar, text="Aktive Vorlage: AzA-Grundvorlage",
font=(_FF, 8), bg="#E0EEF7", fg=_TEXT_SUB, padx=8)
lbl_active_info[0].pack(side="left")
# Zeile 1: Vorlage-Auswahl + Modus-Hinweis
sel_bar = tk.Frame(right, bg=_CARD_BG)
sel_bar.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 2))
tk.Label(sel_bar, text="Vorlage:", font=(_FF, 8),
bg=_CARD_BG, fg=_TEXT_SUB).pack(side="left")
combo_var[0] = tk.StringVar()
combo_widget[0] = ttk.Combobox(sel_bar, textvariable=combo_var[0],
state="readonly", width=32, font=(_FF, 9))
combo_widget[0].pack(side="left", padx=(4, 12))
combo_widget[0].bind("<<ComboboxSelected>>", _on_combo)
lbl_mode[0] = tk.Label(sel_bar, text="", font=(_FF, 7),
bg=_CARD_BG, fg="#B06000", padx=4)
lbl_mode[0].pack(side="left")
# Zeile 2: Textfeld (expandierend)
txt_frame = tk.Frame(right, bg=_CARD_BG)
txt_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(2, 4))
txt_frame.rowconfigure(0, weight=1)
txt_frame.columnconfigure(0, weight=1)
vsb = tk.Scrollbar(txt_frame, orient="vertical")
vsb.grid(row=0, column=1, sticky="ns")
txt_content[0] = tk.Text(txt_frame, font=(_FF, 9),
bg="#FFFFFF", fg=_TEXT,
relief="flat", bd=0, wrap="word",
yscrollcommand=vsb.set,
highlightbackground=_CARD_BD, highlightthickness=1,
padx=8, pady=8)
txt_content[0].grid(row=0, column=0, sticky="nsew")
vsb.config(command=txt_content[0].yview)
txt_content[0].bind("<KeyRelease>", lambda e: _dirty.__setitem__(0, True))
# Zeile 3: Button-Leiste (feste Höhe, nie abgeschnitten)
btn_row = tk.Frame(right, bg="#E8F0F5", pady=8)
btn_row.grid(row=3, column=0, sticky="ew", padx=0)
def _mk(parent, text, cmd, bg, fg="#FFFFFF", width=16):
return tk.Button(parent, text=text, command=cmd,
font=(_FF, 8, "bold"), bg=bg, fg=fg,
relief="flat", bd=0, padx=10, pady=5,
cursor="hand2", width=width,
activebackground=bg)
btn_save[0] = _mk(btn_row, "Speichern", _do_save, _BTN_BLUE, width=12)
btn_save[0].pack(side="left", padx=(10, 4))
_mk(btn_row, "Als neue Vorlage", _do_save_new, _BTN_BLUE, width=16).pack(side="left", padx=4)
btn_activate[0] = _mk(btn_row, "Als aktiv setzen", _do_activate, _BTN_ACT, width=15)
btn_activate[0].pack(side="left", padx=4)
_mk(btn_row, "Löschen", _do_delete, _BTN_RESET, width=10).pack(side="left", padx=4)
btn_publish = _mk(
btn_row, "Veröffentlichen", _do_publish, _BTN_BLUE, width=14,
)
btn_publish.pack(side="left", padx=4)
try:
from aza_ui_helpers import add_tooltip
add_tooltip(btn_publish, "Aktuellen Prompt für andere angemeldete Ärzte freigeben.")
except Exception:
pass
_mk(btn_row, "Schließen", _close, _BTN_CLOSE, width=10).pack(side="right", padx=(4, 10))
_mk(btn_row, "↩ Auf Grundvorlage", _do_reset, _BTN_RESET, width=18).pack(side="right", padx=4)
# Initialer Zustand — immer Krankengeschichte
_sel_dt("kg")