Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer sich pruefbar - nach jeder liefen beide Pruefketten gruen. * Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks. Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm() begrenzt; am unteren Fensterrand lief sie vorher hinaus. Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse. * %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG). * pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py. Die zwei Ausreisser sind mitgezogen: pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py im Rechnungstool schrieb ein eigenes Format. * dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31) und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. * pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt == 1, und der Dateiname wird mitgeprueft. Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF. * .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen. Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen Unterschied, den es im Repository gar nicht gibt. * Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py sagen jetzt, warum sie schweigen duerfen. Die stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung waere Rauschen. * Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=), kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes". 39 Pruefstaende, 0 rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Wo das Programm seine Daten ablegt.
|
|
|
|
Als EXE legt sich das Programm beim ersten Start einen Ordner an - dort liegen
|
|
Journal, Berichte und Log beieinander. Zum Sichern reicht damit dieser eine
|
|
Ordner.
|
|
|
|
1. neben der EXE ein Unterordner "Daten"
|
|
2. geht das nicht (EXE liegt z. B. unter "Programme"), dann der Ordner
|
|
"Beherbergungssteuer" unter LOCALAPPDATA
|
|
|
|
Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien
|
|
noch direkt neben der EXE - so war es frueher -, ziehen sie mit um. Sonst waere
|
|
das Journal ploetzlich "leer" und die Meldung ans Amt faengt bei null an.
|
|
|
|
Aus der Quelle heraus (python app.py) bleibt alles, wo es immer war.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
|
|
log = logging.getLogger("bst.ablage")
|
|
|
|
if getattr(sys, "frozen", False):
|
|
EXE_DIR = os.path.dirname(os.path.abspath(sys.argv[0]))
|
|
else:
|
|
EXE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# Diese Dateien und Ordner gehoeren dem Programm und ziehen mit um.
|
|
EIGENE_DATEN = ("journal.sqlite3", "beherbergungssteuer.log", "berichte")
|
|
|
|
|
|
def _beschreibbar(ordner):
|
|
"""Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten."""
|
|
try:
|
|
os.makedirs(ordner, exist_ok=True)
|
|
probe = os.path.join(ordner, ".schreibprobe")
|
|
with open(probe, "w") as f:
|
|
f.write("x")
|
|
os.remove(probe)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _ist_temp(pfad):
|
|
"""Liegt dieser Pfad im Temp-Ordner von Windows?
|
|
|
|
⚠ Am 07.09.2026 gefunden: der Merkzettel zeigte auf
|
|
%TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
|
|
03.09. Die INSTALLIERTE Fassung fuehrte ihr Journal seitdem im
|
|
Temp-Ordner. Windows raeumt den automatisch auf (Datentraegerbereinigung,
|
|
Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden.
|
|
|
|
Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen - lieber ein
|
|
frischer, richtiger Ordner als ein Journal auf Abruf.
|
|
"""
|
|
import tempfile
|
|
try:
|
|
temp = os.path.realpath(tempfile.gettempdir()).lower()
|
|
ziel = os.path.realpath(pfad).lower()
|
|
return ziel == temp or ziel.startswith(temp + os.sep)
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def firmen_ordner():
|
|
"""Das gemeinsame Dach: %LOCALAPPDATA%/ravokk.
|
|
|
|
Seine Ansage am 07.09.2026: *"es gibt ravokk als main verzeichnis, da
|
|
liegt config und sqlite drin, und dann gibt es den ordner rechnungstool
|
|
und steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma
|
|
und die programme gehoeren zu der firma"*.
|
|
|
|
Darunter liegt je Programm ein eigener Ordner, und das gemeinsame
|
|
Nummernbuch eine Ebene darueber - so nimmt ein Deinstallieren es nicht mit
|
|
(siehe gemeinsam.standard_ordner).
|
|
"""
|
|
# EINE Quelle fuer diesen Pfad. Er wurde an drei Stellen einzeln
|
|
# ausgerechnet - hier, in gemeinsam.py und in der config.py des
|
|
# Rechnungstools. Laeuft eine davon je auseinander, fuehren die beiden
|
|
# Programme zwei Nummernbuecher, und das faellt erst auf, wenn eine
|
|
# Rechnungsnummer zum zweiten Mal vergeben ist.
|
|
import gemeinsam
|
|
return gemeinsam.standard_ordner()
|
|
|
|
|
|
def _zeiger_datei():
|
|
"""Merkzettel, WO der Datenordner liegt - liegt bewusst ausserhalb davon."""
|
|
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
|
return os.path.join(heim, "Beherbergungssteuer", "datenordner.txt")
|
|
|
|
|
|
def _gemerkter_ordner():
|
|
"""(Pfad, Lage): Lage ist "ok", "unerreichbar" oder None (kein Merkzettel).
|
|
|
|
Faengt bewusst mehr als OSError ab: ein zerrissener Merkzettel (Stromausfall
|
|
beim Schreiben) enthaelt Null-Bytes oder kaputte Zeichen - der Fehler wuerde
|
|
sonst beim Import fliegen, also ohne Fenster.
|
|
"""
|
|
try:
|
|
with open(_zeiger_datei(), "r", encoding="utf-8") as f:
|
|
pfad = f.read().strip()
|
|
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
log.debug("Merkzettel nicht lesbar: %s", e)
|
|
return None, None
|
|
if not pfad or "\x00" in pfad or not os.path.isabs(pfad):
|
|
log.warning("Merkzettel unbrauchbar - es wird neu angelegt")
|
|
return None, None
|
|
# HIER wird _ist_temp tatsaechlich benutzt. Bis zum 07.09.2026 stand die
|
|
# Funktion samt Begruendung da und wurde von niemandem gerufen - der
|
|
# Schutz war ein Kommentar, keine Massnahme. Zwei Pruefagenten haben das
|
|
# unabhaengig gefunden, einer mit Messung: das Journal landete weiter im
|
|
# Temp-Ordner.
|
|
if _ist_temp(pfad):
|
|
log.warning("Merkzettel zeigt in den Temp-Ordner - verworfen: %s", pfad)
|
|
return None, None
|
|
try:
|
|
laufwerk = os.path.splitdrive(pfad)[0]
|
|
erreichbar = (not laufwerk) or os.path.exists(
|
|
laufwerk if laufwerk.startswith("\\\\") else laufwerk + os.sep)
|
|
except (OSError, ValueError):
|
|
erreichbar = False
|
|
if not erreichbar:
|
|
return pfad, "unerreichbar"
|
|
return (pfad, "ok") if _beschreibbar(pfad) else (pfad, "unerreichbar")
|
|
|
|
|
|
def _ordner_merken(pfad):
|
|
"""Erst daneben schreiben, dann umlegen - so ist der Merkzettel nie halb."""
|
|
ziel = _zeiger_datei()
|
|
try:
|
|
os.makedirs(os.path.dirname(ziel), exist_ok=True)
|
|
neben = ziel + ".neu"
|
|
with open(neben, "w", encoding="utf-8") as f:
|
|
f.write(pfad)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(neben, ziel)
|
|
except (OSError, ValueError) as e:
|
|
log.warning("Datenordner konnte nicht gemerkt werden: %s", e)
|
|
|
|
|
|
def daten_ordner():
|
|
"""Der Ordner, in dem Journal, Berichte und Log liegen."""
|
|
if not getattr(sys, "frozen", False):
|
|
return EXE_DIR
|
|
|
|
gemerkt, lage = _gemerkter_ordner()
|
|
if lage == "ok":
|
|
return gemerkt
|
|
if lage == "unerreichbar":
|
|
# Stick ab, NAS aus: NICHT still woanders neu anfangen - sonst hat der
|
|
# Nutzer zwei Journale und keinen Weg zurueck.
|
|
log.warning("Datenordner %s ist nicht erreichbar - es wird nichts geändert", gemerkt)
|
|
return gemerkt if _beschreibbar(gemerkt) else EXE_DIR
|
|
|
|
# Erste Wahl: der Ordner der Firma. Dort liegen beide Programme und das
|
|
# gemeinsame Nummernbuch - alles an einer Stelle, und ein Deinstallieren
|
|
# raeumt nur den Programmordner.
|
|
# NEBEN dem Programm, nicht darin - siehe firmen_ordner(). Journal,
|
|
# Berichte und Log liegen damit in ravokk, das Programm in
|
|
# ravokk/Steuerrechnungstool. Ein Deinstallieren leert nur den
|
|
# Programmordner.
|
|
kandidat = firmen_ordner()
|
|
if not _beschreibbar(kandidat):
|
|
# Rueckfall wie frueher: neben der EXE, dann der alte eigene Ordner.
|
|
kandidat = os.path.join(EXE_DIR, "Daten")
|
|
if not _beschreibbar(kandidat):
|
|
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR),
|
|
"Beherbergungssteuer")
|
|
if not _beschreibbar(kandidat):
|
|
return EXE_DIR
|
|
_ordner_merken(kandidat)
|
|
for name in EIGENE_DATEN:
|
|
alt, neu = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)
|
|
if os.path.exists(alt) and not os.path.exists(neu):
|
|
try:
|
|
os.replace(alt, neu)
|
|
log.info("%s in den Datenordner übernommen", name)
|
|
except OSError as e:
|
|
log.warning("%s ließ sich nicht übernehmen: %s", name, e)
|
|
return kandidat
|