3 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 26ac90bb0f Popups kommen nach Klick woanders / Alt+Tab wieder nach vorn; Büroklammer im Tipp des Tages
Fehler: War ein Popup offen und man klickte in ein anderes Programm, ließ sich das Popup über
Hauptfenster, Taskleiste oder Alt+Tab nicht mehr nach vorn holen.
Ursache: Rahmenlose Tk-Fenster mit transient() legt Tk unter Windows als Kindfenster des Desktops
(WS_CHILD) an – ohne Besitzer, daher kam beim Zurückholen nur das (gesperrte) Hauptfenster nach vorn.

- Popups (Dialoge, Rückfragen, Rundgang) ohne transient(), stattdessen Windows-Besitzer +
  Sperre des aufrufenden Fensters wie bei echten modalen Windows-Dialogen (gui/win32.py)
- Rundgang: neue Sprechblase erscheint vor dem Entfernen der alten (sonst springt Windows weg)
- Alt+F4 und Modalsperre robust gegen Timing (Sperre wird bei Bedarf nachgeholt)
- Tests (nur unter Windows/Wine): Besitzer, Sperre, Stapelreihenfolge nach „woanders klicken“
  und Zurückholen, verschachtelte Rückfragen, Rundgang
- Tipp des Tages mit freigestelltem Büroklammer-Bild (build/make_tipp_bild.py), in der .exe
  enthalten und vom Selbsttest geprüft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:26:17 +01:00
adminandClaude Opus 5.5 aae28fa7f2 Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf
geschlossene Datenbank, danach Absturz des Fehler-Handlers.

- beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende
  laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich
- Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster
- Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm)
- Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts)
- tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger
- Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter
  Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:55:00 +01:00
adminandClaude Opus 5.5 4cfe0e6c47 Win95-Titelleiste und -Menüleiste, Einrichtungs-Assistent, Rundgang, Tipp des Tages
- selbst gezeichneter Fensterrahmen (verschieben, maximieren, minimieren, Größe ändern), abschaltbar
- Dialoge mit Win95-Titelleiste; vorherige Modalsperre wird nach Rückfragen wiederhergestellt
- Assistent: Speicherort, Unterhändler (Nummern aus Abrechnung vorschlagen), erste Abrechnung
- Rundgang mit Hinweis-Sprechblasen, Tipp des Tages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:14:19 +01:00
15 changed files with 2303 additions and 50 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@ dist/
build/tmp/ build/tmp/
build/version_info.txt build/version_info.txt
*.db *.db
selbsttest.txt
/*.jpg
/*.png
+22 -1
View File
@@ -5,6 +5,17 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
fertige Excel-Abrechnung. Optik: Windows 95. fertige Excel-Abrechnung. Optik: Windows 95.
## Erster Start
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
(inkl. „Nummern aus Abrechnung vorschlagen“) und die erste Abrechnung. Danach zeigt ein
**Rundgang** mit Hinweis-Sprechblasen die wichtigsten Stellen. Beides sowie den
**Tipp des Tages** gibt es jederzeit im Menü **?**.
Die Windows-95-Titelleiste ist selbst gezeichnet (Windows lässt die echte nicht umfärben).
Falls sie auf einem PC Probleme macht: *Datei ▸ Einstellungen ▸ Darstellung* – dann wird die
normale Windows-Titelleiste verwendet.
## Bedienung in Kürze ## Bedienung in Kürze
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*: 1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
@@ -75,6 +86,8 @@ Aufbau:
| `provtool/export.py` | Excel-Export im Zielformat | | `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) | | `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | | `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
**Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository **Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository
(`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus). (`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus).
@@ -84,9 +97,17 @@ Aufbau:
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller: `tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt - jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip` - Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
Jede Ausnahme in der Oberfläche und jeder Eintrag in `fehler.log` lässt sie fehlschlagen; ein
Watchdog bricht hängende Tests ab, statt den Build zu blockieren. Ohne Bildschirm werden sie lokal
übersprungen, im Build (`PROVTOOL_GUI_TESTS=1`) ist das ein Fehler.
Lokal geht dasselbe mit Docker: Lokal geht dasselbe mit Docker:
```bash ```bash
+28 -2
View File
@@ -9,11 +9,24 @@ export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinam
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}" export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
PY="wine python" PY="wine python"
# Ein virtueller Bildschirm für den GANZEN Build – vor dem ersten Wine-Aufruf starten!
# Wine bindet seinen Hintergrundprozess (wineserver/Desktop) an das DISPLAY des ersten Aufrufs.
# Läuft z. B. „pip install“ ohne Bildschirm und die GUI-Tests später mit, scheitert Tk unter
# Wine mit „Failed to create the menu window“.
wineserver -k 2>/dev/null || true
ANZEIGE=":$((90 + RANDOM % 9))"
Xvfb "$ANZEIGE" -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
trap 'wineserver -k 2>/dev/null || true; kill $XVFB_PID 2>/dev/null || true' EXIT
export DISPLAY="$ANZEIGE"
sleep 1
$PY --version $PY --version
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt $PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests (Windows-Python) ==" echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
$PY -m pytest -q # PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
PROVTOOL_GUI_TESTS=1 $PY -m pytest -q -p no:cacheprovider
echo "== Version ==" echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r') VERSION=$($PY build/version_info.py | tr -d '\r')
@@ -23,6 +36,19 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec $PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -f dist/Provisionsmanager95.exe test -f dist/Provisionsmanager95.exe
echo "== Selbsttest der fertigen .exe =="
rm -f selbsttest.txt
set +e
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
cp README.md dist/LIESMICH.md 2>/dev/null || true cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \ ( cd dist && rm -f Provisionsmanager95-*.zip && \
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
+61
View File
@@ -0,0 +1,61 @@
"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto
für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py <foto>"""
import sys
from collections import deque
from pathlib import Path
from PIL import Image
ZIEL_HOEHE = 170
ROOT = Path(__file__).resolve().parents[1]
def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image:
"""Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß
sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben."""
im = im.convert("RGBA")
w, h = im.size
px = im.load()
hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731
gesehen = bytearray(w * h)
for sy in range(h):
for sx in range(w):
if gesehen[sy * w + sx] or not hell(px[sx, sy]):
continue
flaeche, rand, q = [], False, deque([(sx, sy)])
gesehen[sy * w + sx] = 1
while q:
x, y = q.popleft()
flaeche.append((x, y))
if x in (0, w - 1) or y in (0, h - 1):
rand = True
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]):
gesehen[ny * w + nx] = 1
q.append((nx, ny))
if rand or len(flaeche) >= min_flaeche:
for x, y in flaeche:
px[x, y] = (255, 255, 255, 0)
return im.crop(im.getbbox())
def main(foto: str, ziel: Path, modus: str = "lanczos"):
im = freistellen(Image.open(foto))
faktor = ZIEL_HOEHE / im.height
groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE)
if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren
klein = im.resize((im.width // 8, im.height // 8), Image.BOX)
im = klein.resize(groesse, Image.NEAREST)
else:
im = im.resize(groesse, Image.LANCZOS)
# Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume)
alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
im.putalpha(alpha)
ziel.parent.mkdir(parents=True, exist_ok=True)
im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten
print(ziel, im.size)
if __name__ == "__main__":
main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png",
sys.argv[3] if len(sys.argv) > 3 else "lanczos")
+1
View File
@@ -8,6 +8,7 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")], [os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT], pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"], hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False, noarchive=False,
) )
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.0.0" __version__ = "1.1.2"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+207 -40
View File
@@ -15,7 +15,9 @@ from tkinter import filedialog
from provtool import APP_NAME, config from provtool import APP_NAME, config
from provtool import export as exp from provtool import export as exp
from provtool.db import UNZUGEORDNET, Datenbank from provtool.db import UNZUGEORDNET, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg from provtool.gui import dialogs as dlg
from provtool.gui import rahmen as rh
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER, ImportFehler, lies_datei from provtool.importer import ANBIETER, ImportFehler, lies_datei
from provtool.money import fmt, fmt_percent, parse_cent from provtool.money import fmt, fmt_percent, parse_cent
@@ -62,35 +64,47 @@ class App:
self.haendler_namen: dict[int, str] = {} self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE self.ansicht: int = ALLE
self._edit: tk.Entry | None = None self._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.apply(root) w.apply(root)
self.icons = w.Icons(root) self.icons = w.Icons(root)
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined] root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1)) root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
root.title(APP_NAME)
root.geometry(self.settings.get("fenster", "1200x720"))
root.minsize(900, 520)
root.protocol("WM_DELETE_WINDOW", self.beenden) root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler root.report_callback_exception = self._fehler_handler
klassisch = bool(self.settings.get("klassischer_rahmen", True))
w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(APP_NAME)
self.inhalt = self.rahmen.inhalt
self._menue() self._menue()
self._toolbar() self._toolbar()
self._body() self._body()
self.status = w.Statusleiste(root, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
griff = self.rahmen.griff(self.status)
if griff:
griff.pack(side="right", anchor="se")
self.status.pack(side="bottom", fill="x") self.status.pack(side="bottom", fill="x")
self._tasten() self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False))
abr = self.db.abrechnungen() abr = self.db.abrechnungen()
if abr: if abr:
self.oeffnen(abr[0]["id"]) self.oeffnen(abr[0]["id"])
else: else:
self._aktualisieren() self._aktualisieren()
root.after(300, self._willkommen) if not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
root.after(400, self.assistent)
elif self.settings.get("tipps_beim_start", True):
root.after(500, self.tipp_des_tages)
# ================================================================= Aufbau # ================================================================= Aufbau
def _menue(self): def _menue(self):
m = tk.Menu(self.root, tearoff=0) self.menueleiste = rh.Menueleiste(self.inhalt, self.root)
d = tk.Menu(m, tearoff=0) self.menueleiste.pack(side="top", fill="x")
d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu) d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen) d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_separator() d.add_separator()
@@ -103,32 +117,32 @@ class App:
d.add_command(label="Einstellungen…", command=self.einstellungen) d.add_command(label="Einstellungen…", command=self.einstellungen)
d.add_separator() d.add_separator()
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden) d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
m.add_cascade(label="Datei", menu=d, underline=0)
b = tk.Menu(m, tearoff=0) b = self.menueleiste.menue("Bearbeiten")
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten) b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent) b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
b.add_separator() b.add_separator()
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen) b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
m.add_cascade(label="Bearbeiten", menu=b, underline=0)
h = tk.Menu(m, tearoff=0) h = self.menueleiste.menue("Händler")
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler) h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl) h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen) h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True)) h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
m.add_cascade(label="Händler", menu=h, underline=0)
hi = tk.Menu(m, tearoff=0) hi = self.menueleiste.menue("?", underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
hi.add_command(label="Tipp des Tages…", command=self.tipp_des_tages)
hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent)
hi.add_separator() hi.add_separator()
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen()) hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
m.add_cascade(label="?", menu=hi)
self.root.configure(menu=m)
def _toolbar(self): def _toolbar(self):
tb = tk.Frame(self.root, bg=w.FACE, bd=1, relief="raised") tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
self.tb: dict[str, w.ToolbarButton] = {}
tb.pack(side="top", fill="x") tb.pack(side="top", fill="x")
for text, icon, cmd, tip in ( for text, icon, cmd, tip in (
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"), ("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
@@ -144,7 +158,8 @@ class App:
if text is None: if text is None:
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3) w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
continue continue
w.ToolbarButton(tb, text, self.icons(icon), cmd, tip).pack(side="left", padx=1, pady=1) self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip)
self.tb[text].pack(side="left", padx=1, pady=1)
self.v_suche = tk.StringVar() self.v_suche = tk.StringVar()
sf = tk.Frame(tb, bg=w.FACE) sf = tk.Frame(tb, bg=w.FACE)
sf.pack(side="right", padx=8) sf.pack(side="right", padx=8)
@@ -153,9 +168,10 @@ class App:
e.pack(side="left", padx=4) e.pack(side="left", padx=4)
self.v_suche.trace_add("write", lambda *_: self._tabelle()) self.v_suche.trace_add("write", lambda *_: self._tabelle())
self._suchfeld = e self._suchfeld = e
self._suchrahmen = sf
def _body(self): def _body(self):
desk = tk.Frame(self.root, bg=w.TEAL) desk = tk.Frame(self.inhalt, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True) desk.pack(side="top", fill="both", expand=True)
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0) pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
pw.pack(fill="both", expand=True, padx=6, pady=6) pw.pack(fill="both", expand=True, padx=6, pady=6)
@@ -202,7 +218,8 @@ class App:
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0)) leiste.pack(fill="x", pady=(4, 0))
w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left") self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent"))
self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left") w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True) self.v_alle_auf = tk.BooleanVar(value=True)
@@ -235,12 +252,12 @@ class App:
if self.aid and self.db.abrechnung(self.aid): if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid) self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"] titel = self.db.abrechnung(self.aid)["titel"]
self.root.title(f"{APP_NAME} - [{titel}]") self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}") self.status.set("info", f" Abrechnung: {titel}")
else: else:
self.aid = None self.aid = None
self.positionen = [] self.positionen = []
self.root.title(APP_NAME) self.rahmen.set_titel(APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import") self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
self._haendlerliste(behalte_auswahl) self._haendlerliste(behalte_auswahl)
self._tabelle() self._tabelle()
@@ -579,10 +596,12 @@ class App:
self.ptree.see(iid) self.ptree.see(iid)
self.ptree.update_idletasks() self.ptree.update_idletasks()
bbox = self.ptree.bbox(iid, "ausz") bbox = self.ptree.bbox(iid, "ausz")
if not bbox:
return
pid = int(iid[1:]) pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid) p = next(x for x in self.positionen if x["id"] == pid)
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) → kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
return
x, y, bw, bh = bbox x, y, bw, bh = bbox
var = tk.StringVar(value=fmt(p["betrag"], symbol=False)) var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT) e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
@@ -631,6 +650,25 @@ class App:
e.bind("<Escape>", abbruch) e.bind("<Escape>", abbruch)
e.bind("<FocusOut>", speichern) e.bind("<FocusOut>", speichern)
def _betrag_dialog(self, p: dict):
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
while True:
text = w.eingabe(self.root, "Betrag ändern",
f"Auszahlung für {kunde}\n{p['provisionsart']} – Provision {fmt(p['betrag_orig'])}:",
fmt(p["betrag"], symbol=False))
if text is None:
return
try:
cent = parse_cent(text)
except ValueError:
w.warnung(self.root, "Betrag ändern", "Bitte einen Betrag eingeben, z. B. 25 oder 12,50.")
continue
break
if cent != p["betrag"]:
self.db.betrag_setzen(p["id"], cent)
self.db.touch(self.aid)
self._aktualisieren()
def _kontextmenue(self, e): def _kontextmenue(self, e):
iid = self.ptree.identify_row(e.y) iid = self.ptree.identify_row(e.y)
if not iid: if not iid:
@@ -746,23 +784,33 @@ class App:
self.ansicht = ALLE self.ansicht = ALLE
self._aktualisieren() self._aktualisieren()
def einstellungen(self): def db_wechseln(self, pfad: str | None):
neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() """Öffnet (oder legt an) eine andere Datenbank. pfad=None: Standard-Speicherort."""
if neu and Path(neu) != self.db.path: ziel = Path(pfad) if pfad else config.app_dir() / "provisionen.db"
try: db = Datenbank(ziel) # wirft bei Fehler – alte DB bleibt dann offen
db = Datenbank(neu)
except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
return
self.db.close() self.db.close()
self.db = db self.db = db
self.settings["db_pfad"] = neu self.settings["db_pfad"] = str(ziel) if pfad else None
config.save(self.settings) config.save(self.settings)
abr = self.db.abrechnungen() abr = self.db.abrechnungen()
self.aid = abr[0]["id"] if abr else None self.aid = abr[0]["id"] if abr else None
self.ansicht = ALLE self.ansicht = ALLE
self._aktualisieren() self._aktualisieren()
def einstellungen(self):
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res:
return
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
self.settings["klassischer_rahmen"] = res["klassisch"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path:
try:
self.db_wechseln(res["pfad"])
except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
def hilfe(self): def hilfe(self):
w.info(self.root, "Kurzanleitung", w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
@@ -779,34 +827,153 @@ class App:
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n" "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.") "Alles wird automatisch in der Datenbank gespeichert.")
def _willkommen(self): def assistent(self):
if not self.db.haendler_liste(): res = ass.Einrichtungsassistent(self).zeigen()
if w.frage(self.root, "Willkommen", if self._schliesst:
f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n" return # Programm wird gerade beendet – beim nächsten Start erscheint der Assistent erneut
"Jetzt die Unterhändler mit ihren Kennungen anlegen?"): self.settings["assistent_erledigt"] = True
self.haendler() config.save(self.settings)
self._aktualisieren()
if not res:
return
if res["titel"]:
self.aid = self.db.abrechnung_neu(res["titel"])
self.ansicht = ALLE
self._aktualisieren()
if res["dateien"]:
self.importieren(res["dateien"])
if res["rundgang"]:
self.root.after(200, self.rundgang)
def rundgang(self):
if self._schliesst:
return
tb = self.tb
schritte = [
(self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!",
"In ein paar Schritten sehen Sie, wo Sie was finden.\n\nWeiter geht es mit „Weiter >“ oder der "
"Eingabetaste, beenden können Sie jederzeit mit Esc."),
(tb["Händler"], "Händler",
"Hier legen Sie Ihre Unterhändler an: Name, Auszahlung in % und woran ihre Positionen erkannt werden "
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
(tb["Neu"], "Neue Abrechnung",
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
"Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."),
(tb["Import"], "Dateien importieren",
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
(self.f_h, "Händlerliste",
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Das wird nicht exportiert."),
(self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"• Doppelklick auf eine Position: Betrag frei ändern\n"
"• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n"
"• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
(tb["Export"], "Exportieren",
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."),
(self.status, "Summen",
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
"Provision laut Anbieter und Ihre Auszahlung."),
(self._suchrahmen, "Suchen",
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
(self.menueleiste.knoepfe["?"], "Das war's!",
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n"
"Viel Erfolg mit dem Provisions-Manager 95!"),
]
self._rundgang = ass.Rundgang(self, schritte)
def tipp_des_tages(self):
ass.TippDesTages(self).zeigen()
config.save(self.settings)
def _fehler_handler(self, exc, val, tb): def _fehler_handler(self, exc, val, tb):
text = "".join(traceback.format_exception(exc, val, tb)) text = "".join(traceback.format_exception(exc, val, tb))
try: try:
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f: with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
f.write(text + "\n") f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n")
except OSError: except OSError:
pass pass
# Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr
if self._beendet or self._schliesst or not self._lebt():
return
try:
w.fehler(self.root, "Allgemeine Schutzverletzung", w.fehler(self.root, "Allgemeine Schutzverletzung",
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n" f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
f"Details stehen in {config.app_dir() / 'fehler.log'}") f"Details stehen in {config.app_dir() / 'fehler.log'}")
except tk.TclError:
pass
def _lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def _offene_fenster(self) -> list[tk.Toplevel]:
"""Alle offenen Dialoge (auch verschachtelte), innerste zuletzt."""
out: list[tk.Toplevel] = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, tk.Toplevel):
out.append(kind)
suche(kind)
if self._lebt():
suche(self.root)
return out
def beenden(self): def beenden(self):
"""Programm schließen – auch wenn gerade Dialoge, Rückfragen oder der Rundgang offen sind.
Offene Dialoge werden zuerst geschlossen; der Code, der auf sie gewartet hat, läuft dann mit
„abgebrochen“ zu Ende (Datenbank noch offen). Neue Dialoge öffnen sich in dieser Phase nicht
mehr. Erst danach werden Einstellungen gespeichert, Datenbank und Fenster geschlossen."""
if self._beendet or not self._lebt():
return
self._schliesst = True
self.root._provtool_schliesst = True # type: ignore[attr-defined] # Signal an win95.Dialog
rg = getattr(self, "_rundgang", None)
if rg is not None:
rg.beenden()
if self._edit is not None:
self._edit.destroy()
self._edit = None
offen = self._offene_fenster()
if offen:
for t in reversed(offen):
try: try:
self.settings["fenster"] = self.root.geometry() if t.winfo_exists():
t.destroy()
except tk.TclError:
pass
self.root.after(30, self.beenden) # wenn alle wartenden Abläufe zurückgekehrt sind
return
self._beendet = True
try:
self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings) config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
self.db.close() self.db.close()
finally: finally:
try:
self.root.destroy() self.root.destroy()
except tk.TclError:
pass
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren
sys.exit(ausfuehren(sys.argv[2] if len(sys.argv) > 2 else "selbsttest.txt"))
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
try: # scharfe Darstellung auf HiDPI-Bildschirmen try: # scharfe Darstellung auf HiDPI-Bildschirmen
import ctypes import ctypes
+610
View File
@@ -0,0 +1,610 @@
"""Ersteinrichtung im Stil der Windows-95-Setup-Assistenten, Programm-Rundgang und „Tipp des Tages“."""
from __future__ import annotations
import re
import tkinter as tk
from collections import Counter, defaultdict
from pathlib import Path
from tkinter import filedialog, ttk
from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
from provtool.gui import win32
from provtool.gui import win95 as w
from provtool.importer import lies_datei
from provtool.money import fmt, parse_percent
DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"), ("Alle Dateien", "*.*")]
def _nummern(text: str) -> list[str]:
"""'11111102 (6 Pos.), 27518999' → ['11111102', '27518999']"""
out = []
for teil in text.split(","):
m = re.match(r"\s*([A-Za-z0-9]+)", teil)
if m:
out.append(m.group(1))
return out
# =========================================================================== Assistent
class Einrichtungsassistent(w.Dialog):
BREITE, HOEHE = 470, 340
def __init__(self, app):
super().__init__(app.root, "Provisions-Manager 95 – Einrichtung")
self.app = app
self.dateien: list[str] = []
self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []}
self.caps_anzahl = 0
haupt = tk.Frame(self, bg=w.FACE)
haupt.pack(fill="both", expand=True, padx=10, pady=10)
bild = tk.Canvas(haupt, width=150, height=self.HOEHE, bg=w.TEAL, bd=2, relief="sunken",
highlightthickness=0)
bild.pack(side="left", fill="y")
self._bild(bild)
self.seite = tk.Frame(haupt, bg=w.FACE, width=self.BREITE, height=self.HOEHE)
self.seite.pack(side="left", fill="both", expand=True, padx=(14, 0))
self.seite.pack_propagate(False)
w.trenner(self, vertikal=False).pack(fill="x", padx=10)
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
self.b_abbr = w.knopf(k, "Abbrechen", self.abbrechen, breite=10)
self.b_abbr.pack(side="right")
self.b_weiter = w.knopf(k, "Weiter >", self._weiter, breite=10, default="active")
self.b_weiter.pack(side="right", padx=(0, 10))
self.b_zurueck = w.knopf(k, "< Zurück", self._zurueck, breite=10)
self.b_zurueck.pack(side="right")
self.bind("<Return>", lambda e: self._weiter() if not isinstance(e.widget, tk.Entry) else None)
self.v_titel = tk.StringVar(value=app._vorschlag_titel())
self.v_abrechnung = tk.BooleanVar(value=True)
self.v_rundgang = tk.BooleanVar(value=True)
self.v_db_eigen = tk.BooleanVar(value=bool(app.settings.get("db_pfad")))
self.v_db_pfad = tk.StringVar(value=str(app.db.path))
self.seiten = [self._s_willkommen, self._s_datenbank, self._s_haendler, self._s_abrechnung, self._s_fertig]
self.nr = 0
self._zeige(0)
# ---------------------------------------------------------------- Rahmen
def _bild(self, c: tk.Canvas):
self._img = w.make_icon(self, "app", 7)
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="")
c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
c.create_text(77, self.HOEHE - 38, text="Provisions-\nManager 95", fill=w.WHITE, font=w.FONT_BOLD,
justify="center")
def _leeren(self):
for kind in self.seite.winfo_children():
kind.destroy()
def _ueberschrift(self, text: str):
tk.Label(self.seite, text=text, font=w.FONT_BIG, anchor="w", justify="left",
wraplength=self.BREITE).pack(fill="x", pady=(0, 10))
def _text(self, text: str, master=None, **kw):
lbl = tk.Label(master or self.seite, text=text, anchor="w", justify="left", wraplength=self.BREITE, **kw)
lbl.pack(fill="x", pady=(0, 6))
return lbl
def _zeige(self, nr: int):
self.nr = nr
self._leeren()
self.seiten[nr]()
self.b_zurueck.configure(state="normal" if nr > 0 else "disabled")
self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >")
if self.titelleiste:
self.titelleiste.set_titel(f"Provisions-Manager 95 – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})")
def _weiter(self):
pruefer = getattr(self, f"_pruefe_{self.nr}", None)
if pruefer and not pruefer():
return
if self.nr == len(self.seiten) - 1:
self.result = dict(titel=self.v_titel.get().strip() if self.v_abrechnung.get() else "",
dateien=list(self.dateien) if self.v_abrechnung.get() else [],
rundgang=self.v_rundgang.get())
self.destroy()
return
self._zeige(self.nr + 1)
def _zurueck(self):
if self.nr > 0:
self._zeige(self.nr - 1)
def abbrechen(self):
if w.frage(self, "Einrichtung abbrechen",
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit "
"unter ? ▸ Einrichtungs-Assistent.", default_ja=False):
self.result = None
self.destroy()
# ---------------------------------------------------------------- Seite 1
def _s_willkommen(self):
self._ueberschrift(f"Willkommen beim {APP_NAME}!")
self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:")
for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen",
"die erste Abrechnung anlegen und Dateien importieren"):
self._text(f" • {punkt}")
self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
"Alle Angaben lassen sich später jederzeit ändern.")
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY)
# ---------------------------------------------------------------- Seite 2
def _s_datenbank(self):
self._ueberschrift("Wo sollen die Daten gespeichert werden?")
self._text("In der Datenbank liegen Ihre Händler, die Auszahlungsregeln und alle Abrechnungen "
"mit Ihren Änderungen.")
f = tk.Frame(self.seite, bg=w.FACE)
f.pack(fill="x", pady=6)
from provtool import config
standard = str(config.app_dir() / "provisionen.db")
tk.Radiobutton(f, text="Auf diesem Computer (empfohlen)", variable=self.v_db_eigen, value=False,
command=lambda: self.v_db_pfad.set(standard)).pack(anchor="w")
tk.Label(f, text=standard, fg=w.SHADOW, anchor="w", wraplength=self.BREITE - 30,
justify="left").pack(anchor="w", padx=22)
tk.Radiobutton(f, text="An einem eigenen Ort, z. B. auf einem Netzlaufwerk:", variable=self.v_db_eigen,
value=True).pack(anchor="w", pady=(10, 0))
zeile = tk.Frame(f, bg=w.FACE)
zeile.pack(fill="x", padx=22, pady=2)
e = tk.Entry(zeile, textvariable=self.v_db_pfad)
e.pack(side="left", fill="x", expand=True)
e.bind("<Key>", lambda ev: self.v_db_eigen.set(True))
w.knopf(zeile, "Durchsuchen…", self._db_waehlen).pack(side="left", padx=(4, 0))
self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – "
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW)
def _db_waehlen(self):
p = filedialog.asksaveasfilename(parent=self, title="Speicherort der Datenbank", defaultextension=".db",
initialfile="provisionen.db", confirmoverwrite=False,
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if p:
self.v_db_pfad.set(p)
self.v_db_eigen.set(True)
def _pruefe_1(self) -> bool:
from provtool import config
ziel = self.v_db_pfad.get().strip() if self.v_db_eigen.get() else str(config.app_dir() / "provisionen.db")
if not ziel:
w.warnung(self, "Speicherort", "Bitte einen Speicherort angeben.")
return False
if Path(ziel) != Path(self.app.db.path):
try:
self.app.db_wechseln(ziel if self.v_db_eigen.get() else None)
except Exception as exc:
w.fehler(self, "Speicherort", f"Die Datenbank konnte nicht angelegt werden:\n{exc}")
return False
return True
# ---------------------------------------------------------------- Seite 3
def _s_haendler(self):
db = self.app.db
tk.Label(self.seite, text="Unterhändler anlegen", font=w.FONT_BIG, anchor="w").pack(fill="x")
self._text("Für jeden Unterhändler: Name, Auszahlung und woran seine Positionen zu erkennen sind. "
"Ihre eigene Filiale legen Sie nicht an.", fg=w.SHADOW)
g = tk.Frame(self.seite, bg=w.FACE)
g.pack(fill="x")
self.v_name, self.v_proz = tk.StringVar(), tk.StringVar(value="100")
self.v_fn, self.v_vf, self.v_caps = tk.StringVar(), tk.StringVar(), tk.BooleanVar()
tk.Label(g, text="Name / Firma:").grid(row=0, column=0, sticky="w", pady=1)
e_name = tk.Entry(g, textvariable=self.v_name)
e_name.grid(row=0, column=1, columnspan=2, sticky="we", pady=1)
tk.Label(g, text="Auszahlung:").grid(row=1, column=0, sticky="w", pady=1)
pf = tk.Frame(g, bg=w.FACE)
pf.grid(row=1, column=1, sticky="w")
tk.Entry(pf, textvariable=self.v_proz, width=6, justify="right").pack(side="left")
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
tk.Label(g, text="freenet Unterhändler-Nr.:").grid(row=2, column=0, sticky="w", pady=1)
self.cb_fn = ttk.Combobox(g, textvariable=self.v_fn, values=self.vorschlaege["freenet"])
self.cb_fn.grid(row=2, column=1, columnspan=2, sticky="we", pady=1)
tk.Label(g, text="Vodafone VO-Nr.:").grid(row=3, column=0, sticky="w", pady=1)
self.cb_vf = ttk.Combobox(g, textvariable=self.v_vf, values=self.vorschlaege["vodafone"])
self.cb_vf.grid(row=3, column=1, columnspan=2, sticky="we", pady=1)
caps_besitzer = db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
caps_text = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
if self.caps_anzahl:
caps_text += f" ({self.caps_anzahl} Pos. in der Datei)"
cb = tk.Checkbutton(g, text=caps_text, variable=self.v_caps, wraplength=self.BREITE - 30, justify="left")
cb.grid(row=4, column=0, columnspan=3, sticky="w", pady=(2, 0))
if caps_besitzer:
cb.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: bereits bei „{db.haendler(caps_besitzer).name}“")
g.columnconfigure(1, weight=1)
k = tk.Frame(self.seite, bg=w.FACE)
k.pack(fill="x", pady=(4, 4))
w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left")
w.knopf(k, "Nummern aus Abrechnung vorschlagen…", self._vorschlagen).pack(side="left", padx=6)
tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
lf = tk.Frame(self.seite, bg=w.FACE)
lf.pack(fill="both", expand=True)
self.lb = tk.Listbox(lf, height=4, activestyle="none")
self.lb.pack(side="left", fill="both", expand=True)
w.knopf(lf, "Entfernen", self._haendler_weg).pack(side="left", anchor="n", padx=(6, 0))
self._liste()
e_name.focus_set()
def _liste(self):
self.lb.delete(0, "end")
self._lb_ids = []
for h in self.app.db.haendler_liste():
teile = []
for anb, wert in self.app.db.kennungen(h.id):
teile.append("GROSSBUCHSTABEN" if anb == CAPS_ANBIETER else f"{anb} {wert}")
proz = f"{h.standard_prozent:g} %".replace(".", ",")
self.lb.insert("end", f"{h.name} – {proz} – {', '.join(teile) or 'keine Kennung'}")
self._lb_ids.append(h.id)
def _haendler_dazu(self) -> bool:
name = self.v_name.get().strip()
if not name:
w.warnung(self, "Händler", "Bitte einen Namen eingeben.")
return False
try:
proz = parse_percent(self.v_proz.get())
except ValueError:
w.warnung(self, "Händler", "Die Auszahlung muss eine Zahl sein, z. B. 100 oder 85.")
return False
kenn = [("freenet", n) for n in _nummern(self.v_fn.get())]
kenn += [("vodafone", n) for n in _nummern(self.v_vf.get())]
if self.v_caps.get():
kenn.append((CAPS_ANBIETER, CAPS_WERT))
if not kenn and not w.frage(self, "Händler", f"Für „{name}“ ist keine Kennung angegeben – ihm werden "
"daher keine Positionen zugeordnet.\n\nTrotzdem anlegen?"):
return False
try:
self.app.db.haendler_speichern(Haendler(0, name, standard_prozent=proz), kenn)
except Exception as exc:
msg = str(exc)
if "UNIQUE" in msg:
msg = f"Es gibt bereits einen Händler „{name}“."
w.fehler(self, "Händler", msg)
return False
for v in (self.v_name, self.v_fn, self.v_vf):
v.set("")
self.v_proz.set("100")
self._zeige(self.nr) # neu aufbauen (GROSSBUCHSTABEN-Regel evtl. vergeben)
return True
def _haendler_weg(self):
sel = self.lb.curselection()
if sel:
self.app.db.haendler_loeschen(self._lb_ids[sel[0]])
self._zeige(self.nr)
def _vorschlagen(self):
dateien = filedialog.askopenfilenames(parent=self, title="Abrechnung zum Auslesen der Nummern wählen",
filetypes=DATEITYPEN)
if not dateien:
return
anzahl: dict[str, Counter] = defaultdict(Counter)
summe: dict[tuple[str, str], int] = defaultdict(int)
caps = 0
fehler = []
for f in dateien:
try:
erg = lies_datei(f)
except Exception as exc:
fehler.append(f"{Path(f).name}: {exc}")
continue
if f not in self.dateien:
self.dateien.append(f)
for p in erg.positionen:
anzahl[p.anbieter][p.kennung] += 1
summe[(p.anbieter, p.kennung)] += p.betrag_cent
if p.anbieter == "vodafone" and ist_grossgeschrieben(p.name):
caps += 1
vergeben = self.app.db.kennung_map()
for anb in ("freenet", "vodafone"):
self.vorschlaege[anb] = [f"{k} ({n} Pos., {fmt(summe[(anb, k)])})"
for k, n in anzahl[anb].most_common() if k and (anb, k) not in vergeben]
self.caps_anzahl = caps
self._zeige(self.nr)
if fehler:
w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
else:
w.info(self, "Nummern vorschlagen",
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (▼) bei freenet und Vodafone – "
"sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist "
"Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.")
def _pruefe_2(self) -> bool:
if self.v_name.get().strip():
if w.frage(self, "Händler", f"„{self.v_name.get().strip()}“ wurde noch nicht hinzugefügt.\n\n"
"Jetzt hinzufügen?"):
return self._haendler_dazu() and False # Seite bleibt offen, Liste ist aktualisiert
if not self.app.db.haendler_liste():
return w.frage(self, "Händler", "Es ist noch kein Händler angelegt.\n\n"
"Trotzdem weiter? (Händler lassen sich später unter „Händler“ anlegen.)",
default_ja=False)
return True
# ---------------------------------------------------------------- Seite 4
def _s_abrechnung(self):
self._ueberschrift("Die erste Abrechnung")
self._text("Pro Abrechnungszeitraum (meist ein Monat) legen Sie eine Abrechnung an und importieren "
"dort alle Dateien dieses Zeitraums – z. B. alle freenet-Wochenlisten und die Vodafone-Datei.")
tk.Checkbutton(self.seite, text="Jetzt eine Abrechnung anlegen", variable=self.v_abrechnung,
command=self._s_abrechnung_status).pack(anchor="w")
f = tk.Frame(self.seite, bg=w.FACE)
f.pack(fill="both", expand=True, padx=(22, 0))
z = tk.Frame(f, bg=w.FACE)
z.pack(fill="x", pady=4)
tk.Label(z, text="Bezeichnung:").pack(side="left")
self._e_titel = tk.Entry(z, textvariable=self.v_titel, width=28)
self._e_titel.pack(side="left", padx=6)
tk.Label(f, text="Diese Dateien gleich importieren (optional):", anchor="w").pack(fill="x", pady=(6, 2))
lf = tk.Frame(f, bg=w.FACE)
lf.pack(fill="both", expand=True)
self.lb_d = tk.Listbox(lf, height=5)
self.lb_d.pack(side="left", fill="both", expand=True)
kf = tk.Frame(lf, bg=w.FACE)
kf.pack(side="left", anchor="n", padx=(6, 0))
self._b_dazu = w.knopf(kf, "Hinzufügen…", self._datei_dazu, breite=11)
self._b_dazu.pack(pady=(0, 4))
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
self._b_weg.pack()
for d in self.dateien:
self.lb_d.insert("end", Path(d).name)
self._s_abrechnung_status()
def _s_abrechnung_status(self):
st = "normal" if self.v_abrechnung.get() else "disabled"
for wdg in (self._e_titel, self.lb_d, self._b_dazu, self._b_weg):
wdg.configure(state=st)
def _datei_dazu(self):
for f in filedialog.askopenfilenames(parent=self, title="Dateien zum Import wählen", filetypes=DATEITYPEN):
if f not in self.dateien:
self.dateien.append(f)
self.lb_d.insert("end", Path(f).name)
def _datei_weg(self):
sel = self.lb_d.curselection()
if sel:
del self.dateien[sel[0]]
self.lb_d.delete(sel[0])
def _pruefe_3(self) -> bool:
if self.v_abrechnung.get() and not self.v_titel.get().strip():
w.warnung(self, "Abrechnung", "Bitte eine Bezeichnung eingeben, z. B. „Oktober 2026“.")
return False
return True
# ---------------------------------------------------------------- Seite 5
def _s_fertig(self):
self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.")
self._text(f" • {n} Händler angelegt")
if self.v_abrechnung.get():
self._text(f" • Abrechnung „{self.v_titel.get().strip()}“"
+ (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
self._text("\nSo geht es danach jeden Monat weiter:\n"
" Neu → Import → prüfen & anpassen → Export")
tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
).pack(anchor="w", pady=(12, 0))
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
# =========================================================================== Rundgang
class Rundgang:
"""Führt mit gelben Hinweis-Sprechblasen durch die Oberfläche und rahmt das jeweilige Element rot ein."""
def __init__(self, app, schritte: list[tuple]):
"""schritte: (widget_oder_funktion, titel, text)"""
self.app, self.root, self.schritte = app, app.root, schritte
self.nr = 0
self.blase: tk.Toplevel | None = None
self.rahmen: list[tk.Toplevel] = []
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
self._zeige(0)
@staticmethod
def _entsorgen(blase, rahmen):
for r in rahmen:
try:
r.destroy()
except tk.TclError:
pass
if blase is not None:
try:
blase.grab_release()
except tk.TclError:
pass
try:
blase.destroy()
except tk.TclError:
pass
def _weg(self):
self._entsorgen(self.blase, self.rahmen)
self.rahmen = []
self.blase = None
def beenden(self):
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
self._sperre.beenden()
self._sperre = None
self._weg()
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke),
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
t = tk.Toplevel(self.root, bg=farbe)
t.overrideredirect(True) # kein transient() – siehe win32.py
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
t.update_idletasks()
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
self.rahmen.append(t)
def _zeige(self, nr: int):
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
alt_blase, alt_rahmen = self.blase, self.rahmen
self.blase, self.rahmen = None, []
if getattr(self.root, "_provtool_schliesst", False):
self._entsorgen(alt_blase, alt_rahmen)
return
self.nr = nr
ziel, titel, text = self.schritte[nr]
if callable(ziel) and not isinstance(ziel, tk.Misc):
ziel = ziel()
self.root.update_idletasks()
x, y = ziel.winfo_rootx(), ziel.winfo_rooty()
bw, bh = ziel.winfo_width(), ziel.winfo_height()
self._markieren(x, y, bw, bh)
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
b.withdraw()
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
innen.pack(padx=1, pady=1)
kopf = tk.Frame(innen, bg=w.INFO_BG)
kopf.pack(fill="x")
self._ic = w.make_icon(b, "birne")
tk.Label(kopf, image=self._ic, bg=w.INFO_BG).pack(side="left")
tk.Label(kopf, text=titel, bg=w.INFO_BG, font=w.FONT_BOLD).pack(side="left", padx=6)
tk.Label(innen, text=text, bg=w.INFO_BG, justify="left", anchor="w", wraplength=330).pack(fill="x", pady=(6, 8))
fuss = tk.Frame(innen, bg=w.INFO_BG)
fuss.pack(fill="x")
tk.Label(fuss, text=f"Schritt {nr + 1} von {len(self.schritte)}", bg=w.INFO_BG,
fg=w.SHADOW).pack(side="left")
letzter = nr == len(self.schritte) - 1
w.knopf(fuss, "Fertig" if letzter else "Weiter >", self._weiter, default="active").pack(side="right")
zb = w.knopf(fuss, "< Zurück", self._zurueck)
zb.pack(side="right", padx=4)
if nr == 0:
zb.configure(state="disabled")
if not letzter:
w.knopf(fuss, "Beenden", self.beenden).pack(side="right", padx=(0, 8))
b.bind("<Return>", lambda e: self._weiter())
b.bind("<Right>", lambda e: self._weiter())
b.bind("<Left>", lambda e: self._zurueck())
b.bind("<Escape>", lambda e: self.beenden())
# Position: unter dem Element, sonst darüber, sonst innen oben links; immer im Hauptfenster
b.update_idletasks()
pw, ph = b.winfo_reqwidth(), b.winfo_reqheight()
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
rw, rh = self.root.winfo_width(), self.root.winfo_height()
if y + bh + 12 + ph <= ry + rh:
px, py = x, y + bh + 12
elif y - 12 - ph >= ry:
px, py = x, y - 12 - ph
else:
px, py = x + 40, y + 40
px = min(max(rx + 8, px), rx + rw - pw - 8)
py = min(max(ry + 8, py), ry + rh - ph - 8)
b.geometry(f"{pw}x{ph}+{px}+{py}")
b.deiconify()
b.lift()
b.update_idletasks()
b.focus_force()
if self._sperre is None:
self._sperre = win32.Modal(b, self.root)
else:
win32.besitzer_setzen(b, self.root)
self._entsorgen(alt_blase, alt_rahmen)
try:
b.grab_set()
except tk.TclError:
pass
b.focus_force()
def _weiter(self):
if self.nr >= len(self.schritte) - 1:
self.beenden()
else:
self._zeige(self.nr + 1)
def _zurueck(self):
if self.nr > 0:
self._zeige(self.nr - 1)
# =========================================================================== Tipp des Tages
TIPPS = [
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
"und die Vodafone-Datei in einem Rutsch.",
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent…“, um z. B. nur 80 % "
"auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.",
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit ↓ geht es "
"gleich in die nächste Zeile.",
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
"Provision ab.",
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese "
"Zuordnung hat immer Vorrang.",
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Diese Positionen werden nicht exportiert.",
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.",
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei ▸ Importierte Dateien… – dort lässt "
"sie sich wieder entfernen.",
"Erstellen Sie ab und zu eine Datensicherung: Datei ▸ Datensicherung erstellen…",
"Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.",
"Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.",
]
BILDER = Path(__file__).resolve().parent / "bilder"
def tipp_bild(master) -> tk.PhotoImage | None:
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
try:
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
except tk.TclError:
return None
class TippDesTages(w.Dialog):
def __init__(self, app):
super().__init__(app.root, "Tipp des Tages")
self.app = app
haupt = tk.Frame(self, bg=w.FACE)
haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both")
self._bild = tipp_bild(self)
self.bild_ok = self._bild is not None
if self._bild is None: # Ersatz, falls das Bild fehlt
self._bild = w.make_icon(self, "birne", 3)
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
rechts.pack(side="left", fill="both")
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=8)
self.text.pack(fill="both")
knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
w.knopf(knoepfe, "Nächster Tipp", self._naechster, breite=12).pack()
self.v_start = tk.BooleanVar(value=app.settings.get("tipps_beim_start", True))
tk.Checkbutton(self, text="Tipps beim Start anzeigen", variable=self.v_start).pack(anchor="w", padx=12,
pady=(0, 10))
self.bind("<Return>", lambda e: self.abbrechen())
self._naechster()
def _naechster(self):
nr = self.app.settings.get("tipp_nr", 0) % len(TIPPS)
self.text.configure(text=TIPPS[nr])
self.app.settings["tipp_nr"] = nr + 1
def abbrechen(self):
self.app.settings["tipps_beim_start"] = self.v_start.get()
self.destroy()
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+7 -1
View File
@@ -641,6 +641,12 @@ class EinstellungenDialog(w.Dialog):
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n" tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.", "Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w") justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True)))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)",
variable=self.v_klassisch).pack(anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self): def _waehlen(self):
@@ -651,7 +657,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p) self.v.set(p)
def _ok(self): def _ok(self):
self.result = self.v.get().strip() self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get())
self.destroy() self.destroy()
+408
View File
@@ -0,0 +1,408 @@
"""Selbst gezeichneter Windows-95-Fensterrahmen.
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
"""
from __future__ import annotations
import sys
import tkinter as tk
from provtool.gui import win95 as w
IS_WIN = sys.platform.startswith("win")
# --------------------------------------------------------------------------- Glyphen
GLYPHEN = {
"min": """
........
........
........
........
........
........
kkkkkk..
kkkkkk..""",
"max": """
kkkkkkkkk
kkkkkkkkk
k.......k
k.......k
k.......k
k.......k
k.......k
kkkkkkkkk""",
"restore": """
..kkkkkk
..kkkkkk
..k....k
kkkkkk.k
kkkkkk.k
k....kkk
k....k..
kkkkkk..""",
"close": """
kk....kk
.kk..kk.
..kkkk..
...kk...
..kkkk..
.kk..kk.
kk....kk""",
}
def glyph(master, name: str) -> tk.PhotoImage:
rows = [r.strip() for r in GLYPHEN[name].strip("\n").splitlines() if r.strip()]
breite = max(len(r) for r in rows)
img = tk.PhotoImage(master=master, width=breite, height=len(rows))
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != ".":
img.put(w.PALETTE[ch], (x, y))
return img
def griff_bild(master, groesse: int = 12) -> tk.PhotoImage:
"""Der typische schraffierte Größen-Anfasser unten rechts."""
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
for y in range(groesse):
for x in range(groesse):
d = x + y - (groesse - 1)
if d >= 0:
farbe = (w.HILITE, w.SHADOW, w.SHADOW, None)[d % 4]
if farbe:
img.put(farbe, (x, y))
return img
# --------------------------------------------------------------------------- Win32-Hilfen
def _hwnd(fenster: tk.Misc) -> int:
import ctypes
return ctypes.windll.user32.GetParent(fenster.winfo_id())
def taskleiste_anzeigen(fenster: tk.Tk) -> None:
"""Ein overrideredirect-Fenster erscheint unter Windows sonst nicht in Taskleiste/Alt+Tab."""
if not IS_WIN:
return
try:
import ctypes
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
hwnd = _hwnd(fenster)
stil = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
stil = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, stil)
fenster.wm_withdraw()
fenster.after(10, lambda: (fenster.wm_deiconify(), fenster.focus_force()))
except Exception:
pass
def arbeitsbereich(fenster: tk.Misc) -> tuple[int, int, int, int]:
"""Bildschirmbereich ohne Taskleiste (des Monitors, auf dem das Fenster steht): x, y, breite, höhe."""
if IS_WIN:
try:
import ctypes
from ctypes import wintypes
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)]
mon = ctypes.windll.user32.MonitorFromWindow(_hwnd(fenster), 2) # MONITOR_DEFAULTTONEAREST
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(mon, ctypes.byref(mi)):
r = mi.rcWork
return r.left, r.top, r.right - r.left, r.bottom - r.top
except Exception:
pass
return 0, 0, fenster.winfo_screenwidth(), fenster.winfo_screenheight()
# --------------------------------------------------------------------------- Bausteine
class TitelKnopf(tk.Label):
def __init__(self, master, bild: tk.PhotoImage, command):
super().__init__(master, image=bild, bg=w.FACE, bd=2, relief="raised", width=14, height=12)
self.command = command
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._los)
def _los(self, e):
self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height():
self.command()
class Titelleiste(tk.Frame):
"""Blaue Win95-Titelleiste. knoepfe: Teilmenge von ("min", "max", "close")."""
def __init__(self, master, fenster: tk.Misc, titel: str, icon: tk.PhotoImage | None, knoepfe=("close",),
schliessen=None, minimieren=None, maximieren=None):
super().__init__(master, bg=w.NAVY, height=20)
self.fenster = fenster
self._bilder = {n: glyph(self, n) for n in ("min", "max", "restore", "close")}
if icon is not None:
tk.Label(self, image=icon, bg=w.NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
self.titel_lbl = tk.Label(self, textvariable=self.titel_var, bg=w.NAVY, fg=w.WHITE, font=w.FONT_BOLD,
anchor="w")
self.titel_lbl.pack(side="left", fill="x", expand=True, padx=3)
self.knoepfe: dict[str, TitelKnopf] = {}
if "close" in knoepfe:
self.knoepfe["close"] = TitelKnopf(self, self._bilder["close"], schliessen)
self.knoepfe["close"].pack(side="right", padx=(2, 2), pady=2)
if "max" in knoepfe:
self.knoepfe["max"] = TitelKnopf(self, self._bilder["max"], maximieren)
self.knoepfe["max"].pack(side="right", pady=2)
if "min" in knoepfe:
self.knoepfe["min"] = TitelKnopf(self, self._bilder["min"], minimieren)
self.knoepfe["min"].pack(side="right", pady=2)
self._maximieren = maximieren
for widget in (self, self.titel_lbl):
widget.bind("<ButtonPress-1>", self._start)
widget.bind("<B1-Motion>", self._ziehen)
if maximieren:
widget.bind("<Double-Button-1>", lambda e: maximieren())
self._ab = (0, 0)
self.ziehen_erlaubt = lambda: True
def set_titel(self, t: str):
self.titel_var.set(t)
def max_symbol(self, maximiert: bool):
if "max" in self.knoepfe:
self.knoepfe["max"].configure(image=self._bilder["restore" if maximiert else "max"])
def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
def _ziehen(self, e):
if not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
class Menueleiste(tk.Frame):
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben)."""
def __init__(self, master, root: tk.Misc):
super().__init__(master, bg=w.FACE)
self.root = root
self.knoepfe: dict[str, tk.Menubutton] = {}
def menue(self, label: str, underline: int | None = 0) -> tk.Menu:
mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1,
activebackground=w.NAVY, activeforeground=w.WHITE,
underline=-1 if underline is None else underline)
menu = tk.Menu(mb, tearoff=0)
mb.configure(menu=menu)
mb.pack(side="left")
self.knoepfe[label] = mb
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, m=mb: self.oeffnen(m))
return menu
def oeffnen(self, mb: tk.Menubutton):
menu = mb.nametowidget(mb.cget("menu"))
menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height())
return "break"
# --------------------------------------------------------------------------- Hauptfenster
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520):
self.root, self.klassisch = root, klassisch
self.minbreite, self.minhoehe = minbreite, minhoehe
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen
self.titelleiste = None
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2)
self.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten)
self.titelleiste.pack(fill="x")
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True)
self._rand: str = ""
self.aussen.bind("<Motion>", self._rand_cursor)
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
try:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
sperre = self.root.grab_current()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
fenster = popup
else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError:
fenster = self.root
if fenster is self.root:
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
fenster.destroy()
return "break"
def _offenes_popup(self):
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen") and kind.winfo_ismapped():
gefunden.append(kind)
suche(kind)
suche(self.root)
return gefunden[-1] if gefunden else None
# ---- öffentliche Helfer
def set_titel(self, text: str):
self.root.title(text)
if self.titelleiste:
self.titelleiste.set_titel(text)
def griff(self, master) -> tk.Label | None:
"""Anfasser für die Statusleiste (nur im klassischen Modus)."""
if not self.klassisch:
return None
self._griff_bild = griff_bild(master)
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
g.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se"))
g.bind("<B1-Motion>", self._resize)
return g
def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
self.root.update_idletasks()
if not geo or "+" not in geo:
breite, hoehe = 1200, 720
if geo and "x" in geo:
try:
breite, hoehe = (int(v) for v in geo.split("+")[0].split("x"))
except ValueError:
pass
x0, y0, bw, bh = arbeitsbereich(self.root)
breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo)
if maximiert and self.klassisch:
self.root.after(100, self.maximieren)
def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
# ---- Fensterfunktionen
def minimieren(self):
# Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren
geo = self.root.geometry()
self.root.overrideredirect(False)
self.root.iconify()
def wieder_da(e):
if e.widget is self.root and self.root.state() == "normal":
self.root.unbind("<Map>")
self.root.overrideredirect(True)
self.root.geometry(geo)
taskleiste_anzeigen(self.root)
self.root.bind("<Map>", wieder_da)
def maximieren(self):
if self.maximiert:
return
self._normal_geo = self.root.geometry()
x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
self.maximiert = True
self.titelleiste.max_symbol(True)
def wiederherstellen(self):
if not self.maximiert:
return
self.maximiert = False
if self._normal_geo:
self.root.geometry(self._normal_geo)
self.titelleiste.max_symbol(False)
def umschalten(self):
self.wiederherstellen() if self.maximiert else self.maximieren()
# ---- Größe ändern
def _welcher_rand(self, x, y) -> str:
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "")
return rand
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
CURSOR_X11 = {"n": "top_side", "s": "bottom_side", "e": "right_side", "w": "left_side",
"nw": "top_left_corner", "se": "bottom_right_corner", "ne": "top_right_corner",
"sw": "bottom_left_corner"}
def _rand_cursor(self, e):
if self.maximiert:
return
rand = self._welcher_rand(e.x, e.y)
try:
self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, ""))
except tk.TclError:
pass
def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y)
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
self.root.winfo_width(), self.root.winfo_height())
def _resize(self, e):
if not self._rand or self.maximiert:
return
sx, sy, x, y, bw, bh = self._start
dx, dy = e.x_root - sx, e.y_root - sy
if "e" in self._rand:
bw = max(self.minbreite, bw + dx)
if "s" in self._rand:
bh = max(self.minhoehe, bh + dy)
if "w" in self._rand:
neu = max(self.minbreite, bw - dx)
x, bw = x + (bw - neu), neu
if "n" in self._rand:
neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
+133
View File
@@ -0,0 +1,133 @@
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
"""
from __future__ import annotations
import sys
import tkinter as tk
IS_WIN = sys.platform.startswith("win")
GWLP_HWNDPARENT = -8
GW_OWNER = 4
_u32 = None
def user32():
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
global _u32
if _u32 is None:
import ctypes
from ctypes import wintypes
u = ctypes.WinDLL("user32", use_last_error=True)
u.GetParent.argtypes = [wintypes.HWND]
u.GetParent.restype = wintypes.HWND
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
u.SetWindowLongPtrW.restype = ctypes.c_void_p
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
u.EnableWindow.restype = wintypes.BOOL
u.IsWindowEnabled.argtypes = [wintypes.HWND]
u.IsWindowEnabled.restype = wintypes.BOOL
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetWindow.restype = wintypes.HWND
u.GetLastActivePopup.argtypes = [wintypes.HWND]
u.GetLastActivePopup.restype = wintypes.HWND
u.SetForegroundWindow.argtypes = [wintypes.HWND]
u.SetForegroundWindow.restype = wintypes.BOOL
u.IsWindow.argtypes = [wintypes.HWND]
u.IsWindow.restype = wintypes.BOOL
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetAncestor.restype = wintypes.HWND
u.GetTopWindow.argtypes = [wintypes.HWND]
u.GetTopWindow.restype = wintypes.HWND
u.BringWindowToTop.argtypes = [wintypes.HWND]
u.BringWindowToTop.restype = wintypes.BOOL
_u32 = u
return _u32
def hwnd(fenster: tk.Misc) -> int | None:
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
if not IS_WIN:
return None
try:
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
except (tk.TclError, OSError):
return None
def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster]
out, w = [], user32().GetTopWindow(None)
while w:
if w in ziele:
out.append(ziele.index(w))
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
return out
def nach_vorn(fenster: tk.Misc) -> None:
h = hwnd(fenster)
if h:
user32().SetForegroundWindow(h)
user32().BringWindowToTop(h)
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
h, b = hwnd(fenster), hwnd(besitzer)
if h and b:
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
def besitzer_von(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetWindow(h, GW_OWNER) if h else None
def bedienbar(fenster: tk.Misc, an: bool) -> None:
h = hwnd(fenster)
if h and user32().IsWindow(h):
user32().EnableWindow(h, bool(an))
def ist_bedienbar(fenster: tk.Misc) -> bool:
h = hwnd(fenster)
return bool(user32().IsWindowEnabled(h)) if h else True
def letztes_popup(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetLastActivePopup(h) if h else None
class Modal:
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
self.besitzer = besitzer
self.gesperrt = False
if not IS_WIN:
return
besitzer_setzen(fenster, besitzer)
if sperren and ist_bedienbar(besitzer):
bedienbar(besitzer, False)
self.gesperrt = True
def beenden(self) -> None:
if self.gesperrt:
self.gesperrt = False
try:
bedienbar(self.besitzer, True)
except tk.TclError:
pass
+80 -5
View File
@@ -218,6 +218,23 @@ ICONS = {
...kkwwwwwwkk... ...kkwwwwwwkk...
.....kkkkkkk.... .....kkkkkkk....
................""", ................""",
"birne": """
.....kkkkk......
....kyyyyyk.....
...kyywwyyyk....
..kyywwyyyyyk...
..kywwyyyyyyk...
..kyyyyyyyyyk...
..kyyyyyyyyyk...
...kyyyyyyyk....
....kyyyyyk.....
.....kyyyk......
.....kdddk......
.....kgggk......
.....kdddk......
.....kgggk......
......kkk.......
................""",
"app": """ "app": """
................ ................
.....kkkkkkk.... .....kkkkkkk....
@@ -480,6 +497,10 @@ def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4):
# --------------------------------------------------------------------------- Dialoge # --------------------------------------------------------------------------- Dialoge
# Klassischer Win95-Rahmen auch für Dialoge (wird von der App gesetzt, abschaltbar in den Einstellungen)
KLASSISCH = False
class Dialog(tk.Toplevel): class Dialog(tk.Toplevel):
"""Modaler Dialog im Win95-Stil.""" """Modaler Dialog im Win95-Stil."""
@@ -487,33 +508,87 @@ class Dialog(tk.Toplevel):
super().__init__(master, bg=FACE) super().__init__(master, bg=FACE)
self.withdraw() self.withdraw()
self.title(titel) self.title(titel)
if not KLASSISCH:
# Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk
# sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable) self.resizable(resizable, resizable)
self.result = None self.result = None
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
self.protocol("WM_DELETE_WINDOW", self.abbrechen) self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen()) self.bind("<Escape>", lambda e: self.abbrechen())
try: try:
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined] self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
except Exception: except Exception:
pass pass
self.titelleiste = None
if KLASSISCH:
from provtool.gui.rahmen import Titelleiste
self.overrideredirect(True)
self.configure(bd=2, relief="raised", padx=2, pady=2)
self.titelleiste = Titelleiste(self, self, titel, None, ("close",), schliessen=self.abbrechen)
self.titelleiste.pack(side="top", fill="x")
def zeigen(self, fokus=None): def zeigen(self, fokus=None):
if getattr(self._root(), "_provtool_schliesst", False):
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
return None
self.update_idletasks() self.update_idletasks()
m = self.master.winfo_toplevel() m = self.master.winfo_toplevel()
w, h = self.winfo_reqwidth(), self.winfo_reqheight() w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2) x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3) y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
self.geometry(f"+{x}+{y}") self.geometry(f"+{max(0, x)}+{max(0, y)}")
self.deiconify() self.deiconify()
self.lift() self.lift()
try: # Windows: Dialog gehört dem aufrufenden Fenster, das solange gesperrt ist → Klick aufs
self.grab_set() # Hauptfenster, Taskleiste und Alt+Tab holen automatisch den Dialog nach vorn.
except tk.TclError: from provtool.gui.win32 import Modal
pass self.update_idletasks()
self.focus_force()
self._modal = Modal(self, m)
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
self._sperre_setzen()
bind_id = None
if KLASSISCH:
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
bind_id = m.bind("<FocusIn>", lambda e: self.winfo_exists() and self.lift(), add="+")
self.focus_force()
(fokus or self).focus_set() (fokus or self).focus_set()
self.wait_window() self.wait_window()
if bind_id:
try:
m.unbind("<FocusIn>", bind_id)
except tk.TclError:
pass
# Tk gibt beim Schließen die Sperre frei, stellt die vorige aber nicht wieder her
try:
if vorher is not None and vorher.winfo_exists():
vorher.grab_set()
vorher.focus_force()
except tk.TclError:
pass
return self.result return self.result
def _sperre_setzen(self, versuch: int = 0):
"""Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine),
schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen."""
try:
if self.winfo_exists():
self.grab_set()
except tk.TclError:
if versuch < 50:
self.after(20, lambda: self._sperre_setzen(versuch + 1))
def destroy(self):
# Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows
# beim Schließen ein fremdes Programm statt unseres Fensters.
modal = getattr(self, "_modal", None)
if modal is not None:
modal.beenden()
self._modal = None
super().destroy()
def abbrechen(self): def abbrechen(self):
self.result = None self.result = None
self.destroy() self.destroy()
+182
View File
@@ -0,0 +1,182 @@
"""Selbsttest der fertigen Programmdatei: `Provisionsmanager95.exe --selbsttest ergebnis.txt`
Läuft im Build (Wine + virtueller Bildschirm) gegen die tatsächlich ausgelieferte .exe. Startet die
echte Oberfläche in einem Wegwerf-Profil, spielt kritische Abläufe durch und schreibt „OK“ bzw. die
gefundenen Fehler in die Ergebnisdatei (Exit-Code 0 = bestanden).
Geprüft wird u. a. der Fall „Programm schließen, während der Einrichtungs-Assistent offen ist“
sowie Import und Export (openpyxl/xlrd müssen in der .exe enthalten sein).
"""
from __future__ import annotations
import os
import sys
import tempfile
import time
import tkinter as tk
import traceback
from pathlib import Path
ZEITLIMIT = 60 # Sekunden je Szenario
class _Szenario:
def __init__(self, name: str, home: Path, einstellungen: str | None = None):
self.name, self.home, self.fehler = name, home, []
home.mkdir(parents=True, exist_ok=True)
os.environ["PROVTOOL_HOME"] = str(home)
if einstellungen:
(home / "einstellungen.json").write_text(einstellungen, encoding="utf-8")
from provtool.gui.app import App
self.root = tk.Tk()
self.app = App(self.root)
echt = self.root.report_callback_exception
def melden(exc, val, tb):
self.fehler.append("".join(traceback.format_exception(exc, val, tb)))
try:
echt(exc, val, tb)
except Exception:
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
self.root.report_callback_exception = melden
def lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def dialog(self, typ):
from provtool.gui import win95 as w
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, w.Dialog) and isinstance(kind, typ) and kind.winfo_exists():
gefunden.append(kind)
suche(kind)
if self.lebt():
suche(self.root)
return gefunden[0] if gefunden else None
def ablauf(self, schritte):
offen, start = list(schritte), time.time()
def poll():
if not self.lebt():
return
if time.time() - start > ZEITLIMIT:
self.fehler.append(f"Zeitüberschreitung bei: {offen[0][2] if offen else 'Ende'}")
try:
self.root.destroy()
except tk.TclError:
pass
return
if offen:
try:
bereit = offen[0][0]()
except Exception:
bereit = False
if bereit:
_, aktion, _ = offen.pop(0)
def ausfuehren(a=aktion):
try:
a()
except Exception:
self.fehler.append(traceback.format_exc())
self.root.after(0, ausfuehren)
self.root.after(20, poll)
self.root.after(20, poll)
self.root.mainloop()
if offen:
self.fehler.append("Nicht ausgeführt: " + ", ".join(s[2] for s in offen))
log = self.home / "fehler.log"
if log.exists() and log.read_text(encoding="utf-8").strip():
self.fehler.append("fehler.log:\n" + log.read_text(encoding="utf-8"))
return [f"[{self.name}] {f}" for f in self.fehler]
def _beispiel_csv(pfad: Path):
kopf = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;"
"Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag")
zeilen = [kopf,
"2026.10.01-2026.10.07;SIM-Karten;1;111;1;05.10.2026;;1601111111;;;;;Muster GmbH;;Allnet;0;8",
"2026.10.01-2026.10.07;Sonderbonus Aktion;1;111;2;06.10.2026;;1602222222;;;;;Beispiel KG;;Classic;0;170"]
pfad.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
def ausfuehren(ergebnisdatei: str) -> int:
from provtool.gui.assistent import Einrichtungsassistent
basis = Path(tempfile.mkdtemp(prefix="pm95-selbsttest-"))
fehler: list[str] = []
immer = lambda: True # noqa: E731
try:
# 1) Gemeldeter Absturz: Schließen, während der Einrichtungs-Assistent offen ist
s = _Szenario("Schließen bei offenem Assistenten", basis / "a")
cmd = s.root.tk.call("wm", "protocol", s.root._w, "WM_DELETE_WINDOW")
fehler += s.ablauf([(lambda: s.dialog(Einrichtungsassistent) is not None, lambda: None, "Assistent erscheint"),
(immer, lambda: s.root.tk.call(cmd), "Fenster schließen")])
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv"
_beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b")
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
def haendler():
wiz().v_name.set("Selbsttest-Händler")
wiz().v_fn.set("111")
wiz()._haendler_dazu()
def abrechnung():
wiz().dateien.append(str(csv))
wiz().v_rundgang.set(False)
wiz()._weiter()
def import_bericht_zu():
from provtool.gui.dialogs import ImportBericht
s.dialog(ImportBericht).abbrechen()
def export():
from provtool import export as exp
app = s.app
pos = [p for p in app.positionen if p["haendler_id"] > 0]
dateien = exp.exportiere_haendler(basis / "export", "Selbsttest", pos, "Test")
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht
def tipp_pruefen():
d = s.dialog(TippDesTages)
if not d.bild_ok:
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
d.abbrechen()
fehler += s.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
(seite(2), haendler, "Händler anlegen"),
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"),
(seite(3), abrechnung, "Abrechnung mit Datei"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
])
except Exception:
fehler.append(traceback.format_exc())
Path(ergebnisdatei).write_text("OK\n" if not fehler else "FEHLER\n\n" + "\n\n".join(fehler), encoding="utf-8")
return 0 if not fehler else 1
if __name__ == "__main__":
sys.exit(ausfuehren(sys.argv[1] if len(sys.argv) > 1 else "selbsttest.txt"))
+560
View File
@@ -0,0 +1,560 @@
"""GUI-Tests: bedienen die echte Oberfläche (unter Xvfb bzw. Wine+Xvfb im Build).
Jede Ausnahme in einem Tk-Callback, jeder Absturz des Fehler-Handlers und jeder Eintrag in
fehler.log lässt den Test fehlschlagen. Modale Dialoge blockieren eine eigene Ereignisschleife –
deshalb laufen die Testschritte als Tk-Callbacks (Schrittplan) und ein Watchdog bricht hängende
Tests nach einer Zeitgrenze ab, statt den Build zu blockieren.
Ohne Bildschirm werden die Tests übersprungen – außer PROVTOOL_GUI_TESTS=1 ist gesetzt (Build).
"""
from __future__ import annotations
import json
import os
import time
import tkinter as tk
import traceback
import pytest
from provtool import config
from provtool.db import Datenbank, Haendler
PFLICHT = os.environ.get("PROVTOOL_GUI_TESTS") == "1"
IMMER = lambda: True # noqa: E731
class Lauf:
"""Startet die App in einem eigenen Profil, führt Schrittpläne aus und sammelt alle Fehler."""
def __init__(self, home, einstellungen: dict | None = None):
self.home = home
if einstellungen is not None:
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None
for versuch in range(3):
try:
self.root = tk.Tk()
break
except tk.TclError as exc:
# Unter Wine scheitert das Anlegen vieler Tk-Instanzen in einem Prozess gelegentlich
# mit „couldn't read file init.tcl: Success“ – das betrifft nur die Tests (die App
# legt genau eine Instanz an). Daher kurz warten und erneut versuchen.
if "init.tcl" in str(exc) and versuch < 2:
time.sleep(0.5)
continue
if PFLICHT:
raise
pytest.skip(f"kein Bildschirm: {exc}")
self.fehler: list[str] = []
from provtool.gui import app as A
self.app = A.App(self.root)
echt = self.root.report_callback_exception
def melden(exc, val, tb):
self.fehler.append("".join(traceback.format_exception(exc, val, tb)))
try:
echt(exc, val, tb) # der echte Fehler-Handler darf dabei selbst nicht abstürzen
except Exception:
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
# eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt
self.root.after(100, self._meldungen_zu)
self.root.report_callback_exception = melden
# ---------------------------------------------------------------- Zustand
@property
def lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def dialoge(self) -> list[tk.Toplevel]:
from provtool.gui import win95 as w
out = []
def suche(widget):
try:
kinder = widget.winfo_children()
except tk.TclError:
return
for kind in kinder:
if isinstance(kind, w.Dialog) and kind.winfo_exists():
out.append(kind)
suche(kind)
if self.lebt:
suche(self.root)
return out
def dialog(self, typ):
return next((d for d in self.dialoge() if isinstance(d, typ)), None)
def _meldungen_zu(self):
for d in reversed(self.dialoge()):
if d.winfo_exists() and d.title() == "Allgemeine Schutzverletzung":
d.destroy()
def _notaus(self):
for d in reversed(self.dialoge()):
try:
d.destroy()
except tk.TclError:
pass
try:
self.root.destroy()
except tk.TclError:
pass
# ---------------------------------------------------------------- Ablauf
def ablauf(self, schritte: list[tuple], sekunden: float = 20.0, nachlauf: float = 0.3):
"""schritte: [(bedingung, aktion, beschreibung), ...] – jede Aktion läuft als eigener
Tk-Callback, sobald ihre Bedingung erfüllt ist (auch während ein modaler Dialog offen ist)."""
offen = list(schritte)
start = time.time()
zustand = {"fertig_seit": None}
def poll():
if not self.lebt:
return
if time.time() - start > sekunden:
text = offen[0][2] if offen else "(nach dem letzten Schritt hängt ein Dialog)"
self.fehler.append(f"Zeitüberschreitung – hängt bei: {text}")
self._notaus()
return
if offen:
bed, akt, _ = offen[0]
try:
bereit = bed()
except Exception:
bereit = False
if bereit:
offen.pop(0)
self.root.after(0, akt)
elif zustand["fertig_seit"] is None:
zustand["fertig_seit"] = time.time()
self.root.after(20, poll)
self.root.after(20, poll)
while self.lebt:
try:
self.root.update()
except tk.TclError:
break
f = zustand["fertig_seit"]
if f is not None and time.time() - f >= nachlauf:
break
time.sleep(0.01)
self.uebrig = [s[2] for s in offen]
def schliessen_ueber_fenster(self):
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW")
self.root.tk.call(cmd)
def pruefen(self):
log = self.home / "fehler.log"
inhalt = log.read_text(encoding="utf-8") if log.exists() else ""
assert not self.fehler, "Fehler in der Oberfläche:\n\n" + "\n".join(self.fehler)
assert not inhalt, "fehler.log wurde geschrieben:\n" + inhalt
def ende(self):
if self.lebt:
self.ablauf([(IMMER, self.app.beenden, "beenden")], sekunden=10)
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
return tmp_path
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([
FREENET_KOPF,
freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet", "8"),
freenet_zeile("Sonderbonus Aktion", "111", "06.10.2026", "1602222222", "Beispiel KG", "Classic", "170"),
freenet_zeile("SIM-Karten", "999", "07.10.2026", "1603333333", "Eigener Kunde", "Flex", "8"),
]) + "\r\n").encode("cp1252"))
db = Datenbank(config.db_path())
db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close()
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf
lauf.ende()
def _assistent_offen(lauf):
from provtool.gui.assistent import Einrichtungsassistent
return lambda: lauf.dialog(Einrichtungsassistent) is not None
# =========================================================================== Schließen bei offenem Dialog
@pytest.mark.parametrize("weg", ["fenster", "beenden"])
def test_schliessen_waehrend_assistent(home, weg):
"""Reproduziert den gemeldeten Absturz: Programm schließen, während der Assistent offen ist."""
lauf = Lauf(home) # frisches Profil → Assistent startet automatisch
zu = {"fenster": lauf.schliessen_ueber_fenster, "beenden": lauf.app.beenden}[weg]
lauf.ablauf([(_assistent_offen(lauf), lambda: None, "Assistent erscheint"),
(IMMER, zu, f"schließen über {weg}")])
assert not lauf.lebt, "Programm wurde nicht beendet"
lauf.pruefen()
def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
"""Alt+F4 bei offenem Assistenten = „Abbrechen“ mit Rückfrage; das Programm bleibt offen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Einrichtung abbrechen"), None) # noqa: E731
def ja_klicken():
d = rueckfrage()
d.result = True
d.destroy()
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).event_generate("<Alt-F4>"), "Alt+F4"),
(lambda: rueckfrage() is not None, ja_klicken, "Rückfrage mit Ja beantworten"),
(lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
])
assert lauf.lebt, "Programm darf nicht beendet werden"
assert not lauf.uebrig, lauf.uebrig
assert lauf.app.settings.get("assistent_erledigt") is True
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten):
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None,
lambda: lauf.dialog(InfoDialog).event_generate("<Alt-F4>"), "Alt+F4 im Dialog"),
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
assert lauf.lebt, "Alt+F4 im Dialog darf nicht das ganze Programm beenden"
assert not lauf.uebrig, lauf.uebrig
lauf.pruefen()
def _dialog_oeffner():
from provtool.gui import dialogs as dlg
return {
"haendler": lambda a: a.haendler(),
"abrechnungen": lambda a: a.abrechnung_waehlen(),
"export": lambda a: a.exportieren(),
"einstellungen": lambda a: a.einstellungen(),
"neu": lambda a: a.neu(),
"info": lambda a: dlg.InfoDialog(a.root).zeigen(),
"tipp": lambda a: a.tipp_des_tages(),
"dateien": lambda a: a.dateien(),
"hilfe": lambda a: a.hilfe(),
"assistent": lambda a: a.assistent(),
"prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()),
"neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
a.neu_berechnen()),
}
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
assert not lauf.lebt, f"Programm wurde nicht beendet (offen: {lauf.uebrig})"
lauf.pruefen()
def test_schliessen_waehrend_rueckfrage_im_assistent(home):
"""Assistent offen → „Abbrechen“ → Rückfrage offen → Programm schließen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).abbrechen(), "Abbrechen klicken"),
(lambda: len(lauf.dialoge()) >= 2, lauf.schliessen_ueber_fenster, "Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_schliessen_waehrend_rundgang(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lauf.app.rundgang, "Rundgang starten"),
(lambda: getattr(lauf.app, "_rundgang", None) and lauf.app._rundgang.blase, lauf.schliessen_ueber_fenster,
"Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten):
lauf = lauf_mit_daten
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
lauf.ablauf([
(IMMER, lauf.app.rundgang, "Rundgang starten"),
(lambda: rg() is not None and rg().blase is not None,
lambda: rg().blase.event_generate("<Alt-F4>"), "Alt+F4"),
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
], sekunden=8)
assert lauf.lebt, "Alt+F4 im Rundgang darf nicht das Programm beenden"
assert not lauf.uebrig, lauf.uebrig
lauf.pruefen()
def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten):
"""Ist die Betragszelle nicht sichtbar, öffnet sich ein Dialog statt still nichts zu tun."""
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
eingabe = lambda: next((d for d in lauf.dialoge() if d.title() == "Betrag ändern"), None) # noqa: E731
def tippen():
d = eingabe()
entry = next(k for k in d.winfo_children() if isinstance(k, tk.Entry))
entry.delete(0, "end")
entry.insert(0, "7,77")
d.result = entry.get()
d.destroy()
def oeffnen():
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.ptree.bbox = lambda *a, **k: "" # Zelle „unsichtbar“
app.betrag_bearbeiten(pos)
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"),
])
assert not lauf.uebrig, lauf.uebrig
assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
del app.ptree.bbox
lauf.pruefen()
def test_fehler_handler_nach_zerstoertem_fenster(home):
"""Der Fehler-Handler darf nicht abstürzen, wenn das Fenster schon weg ist – nur protokollieren."""
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
app = lauf.app
lauf.root.destroy()
try:
raise RuntimeError("Testfehler")
except RuntimeError as exc:
app._fehler_handler(type(exc), exc, exc.__traceback__) # darf nicht werfen
assert "RuntimeError: Testfehler" in (home / "fehler.log").read_text(encoding="utf-8")
def test_zweimal_beenden(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([(IMMER, lambda: (lauf.app.beenden(), lauf.app.beenden()), "zweimal beenden")])
assert not lauf.lebt
lauf.pruefen()
# =========================================================================== Rauchtests der Abläufe
def test_assistent_komplett_durchklicken(home):
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
wiz = lambda: lauf.dialog(Einrichtungsassistent) # noqa: E731
def haendler_eintragen():
wiz().v_name.set("Testhändler")
wiz().v_fn.set("111 (3 Pos., 10,00 €)")
wiz().v_caps.set(True)
assert wiz()._haendler_dazu()
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
lauf.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen → Speicherort"),
(seite(1), lambda: wiz()._weiter(), "Speicherort → Händler"),
(seite(2), haendler_eintragen, "Händler eintragen"),
(lambda: len(lauf.app.db.haendler_liste()) == 1, lambda: wiz()._zurueck(), "zurück"),
(seite(1), lambda: wiz()._weiter(), "wieder vor"),
(seite(2), lambda: wiz()._weiter(), "Händler → Abrechnung"),
(seite(3), lambda: wiz()._weiter(), "Abrechnung → Fertig"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: getattr(lauf.app, "_rundgang", None) is not None and lauf.app._rundgang.blase is not None,
lambda: [lauf.app._rundgang._weiter() for _ in range(len(lauf.app._rundgang.schritte))],
"Rundgang komplett durchklicken"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
db = lauf.app.db
h = db.haendler_liste()
assert [x.name for x in h] == ["Testhändler"]
assert ("vodafone_caps", "*") in db.kennungen(h[0].id)
assert [a["titel"] for a in db.abrechnungen()]
assert lauf.app._rundgang.blase is None
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_hauptfenster_ablaeufe(lauf_mit_daten):
from provtool.gui.dialogs import ProzentDialog
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
def betrag_aendern():
# Feld öffnen und sofort tippen: ohne Fenstermanager (Xvfb/Wine) verliert das Feld sonst
# gleich den Fokus und speichert unverändert – genau wie beim Wegklicken.
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.betrag_bearbeiten(pos)
lauf.root.update_idletasks()
e = app._edit
e.focus_force()
e.delete(0, "end")
e.insert(0, "5,55")
e.event_generate("<Return>")
if app._edit is e:
# Unter Windows gehen erzeugte Tastendrücke an das Fenster mit Fokus – ohne Fenster-
# manager hat das Feld ihn evtl. nicht. Dann wie „wegklicken“ speichern (FocusOut).
e.event_generate("<FocusOut>")
def prozent_ok():
d = lauf.dialog(ProzentDialog)
d.v.set("80")
d._ok()
def fenster():
if app.rahmen.klassisch:
app.rahmen.maximieren()
app.rahmen.wiederherstellen()
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children())
and bool(app.ptree.bbox(app.ptree.get_children(app.ptree.get_children()[0])[0], "ausz")),
betrag_aendern, "Betrag inline ändern (sobald die Zeile sichtbar ist)"),
(lambda: any(p["betrag"] == 555 and p["manuell"] for p in app.positionen),
lambda: (app.ptree.selection_set(app.ptree.get_children()[-1]), app.prozent()), "Prozent-Dialog"),
(lambda: lauf.dialog(ProzentDialog) is not None, prozent_ok, "80 % setzen"),
(lambda: lauf.dialog(ProzentDialog) is None, lambda: app.v_suche.set("Beispiel"), "suchen"),
(IMMER, lambda: app.v_suche.set(""), "Suche leeren"),
(IMMER, fenster, "maximieren/wiederherstellen"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
assert any(p["prozent"] == 80 for p in app.positionen)
lauf.pruefen()
# =========================================================================== Windows: Popups bleiben erreichbar
import sys # noqa: E402
nur_windows = pytest.mark.skipif(not sys.platform.startswith("win"),
reason="Win32-Fensterbesitz gibt es nur unter Windows (im Build: Wine)")
@nur_windows
def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
"""Gemeldet: Popup offen, woanders hingeklickt – danach kam das Popup nicht mehr nach vorn
(auch nicht per Alt+Tab). Geprüft wird: Popup gehört dem Hauptfenster, das Hauptfenster ist
gesperrt, und holt man das Hauptfenster zurück, liegt das Popup darüber."""
from provtool.gui import win32
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(InfoDialog)
fremd = tk.Toplevel(lauf.root) # steht für ein anderes Programm
fremd.geometry("400x300+50+50")
fremd.update()
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root),
haupt_bedienbar=win32.ist_bedienbar(lauf.root))
win32.nach_vorn(fremd) # „woanders hingeklickt“
lauf.root.update()
m["woanders"] = win32.stapel(d, lauf.root, fremd)
win32.nach_vorn(lauf.root) # Alt+Tab / Taskleiste zurück zu uns
lauf.root.update()
m["zurueck"] = win32.stapel(d, lauf.root, fremd)
fremd.destroy()
d.abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"),
(lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
], sekunden=10)
assert not lauf.uebrig, lauf.uebrig
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
assert m["haupt_bedienbar"] is False, "Hauptfenster muss gesperrt sein, solange das Popup offen ist"
assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})"
assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
lauf.pruefen()
@nur_windows
def test_windows_verschachtelte_rueckfrage(lauf_mit_daten):
"""Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei."""
from provtool.gui import win32
from provtool.gui import win95 as w
from provtool.gui.dialogs import HaendlerDialog
lauf = lauf_mit_daten
m = {}
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731
def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h),
haupt_an=win32.ist_bedienbar(lauf.root))
r.destroy()
def messen_aussen():
h = lauf.dialog(HaendlerDialog)
m.update(h_an_danach=win32.ist_bedienbar(h), haupt_an_danach=win32.ist_bedienbar(lauf.root))
h.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.haendler, "Händler-Dialog öffnen"),
(lambda: lauf.dialog(HaendlerDialog) is not None,
lambda: w.info(lauf.dialog(HaendlerDialog), "Test-Rückfrage", "?"), "Rückfrage öffnen"),
(lambda: rueckfrage() is not None, lambda: lauf.root.after(100, messen_innen), "innen messen"),
(lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
], sekunden=12)
assert not lauf.uebrig, lauf.uebrig
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
assert m["h_an"] is False and m["haupt_an"] is False
assert m["h_an_danach"] is True and m["haupt_an_danach"] is False
assert win32.ist_bedienbar(lauf.root)
lauf.pruefen()
@nur_windows
def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
from provtool.gui import win32
lauf = lauf_mit_daten
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
m = {}
def messen_und_weiter():
m.setdefault("gesperrt", []).append(not win32.ist_bedienbar(lauf.root))
m.setdefault("besitzer", []).append(win32.besitzer_von(rg().blase) == win32.hwnd(lauf.root))
rg()._weiter()
schritte = [(IMMER, lauf.app.rundgang, "Rundgang starten")]
for i in range(3):
schritte.append((lambda i=i: rg() is not None and rg().blase is not None and rg().nr == i,
messen_und_weiter, f"Schritt {i + 1}"))
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
lauf.ablauf(schritte, sekunden=12)
assert not lauf.uebrig, lauf.uebrig
assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
lauf.pruefen()