Steuerrechnungstool/ablage.py
TheMockTv 96671027c8 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
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
2026-09-07 18:39:03 +02:00

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