Files
kbcrm/App_Server/root/crm/model/checkdb.py
T
gg 44c411e5d7 fix(crm): DB-Migrationen idempotent machen für Neuinstallationen
Bei Neuinstallation erzeugt create_all bereits das gesamte Schema,
so dass nachfolgende ALTER-Table-Migrationen fehlschlagen ("duplicate
column name"). V01_04 prüft nun die Spalten-Existenz per PRAGMA
table_info. checkupdates läuft wieder sequentiell ohne Überspringen.
Konvention in AGENTS.md dokumentiert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 17:36:52 +02:00

249 lines
9.3 KiB
Python

"""
DB-Versionierung für die CRM-Datenbank (kbp_crm.db).
Analog zu auth/model/checkdb.py: Beim Start wird checkupdates() aufgerufen und
führt alle _db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist als die
in der Systeminfo-Tabelle vermerkte. Neue Migrationen werden als weitere
_db_Vxx_xx-Funktionen ergänzt.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-02
:version: 1.0
"""
from datetime import datetime
from sqlalchemy.exc import OperationalError
from db import dbapp
from crm.model import CrmSysteminfo
BINDKEY = "appdb"
session = dbapp.session
def _updatesysteminfo(versionnr: str):
"""Aktualisiert Versionsnummer und Zeitstempel in der Systeminfo-Tabelle."""
s: CrmSysteminfo = CrmSysteminfo.query.get(1)
s.version = versionnr
s.lastupdate = datetime.now()
def _db_V01_00():
"""
Ersteinrichtung: Alle CRM-Tabellen anlegen und Systeminfo-Datensatz
schreiben.
"""
dbapp.create_all(bind_key=BINDKEY)
systeminfo = CrmSysteminfo()
systeminfo.id = 1
systeminfo.version = "01.00"
systeminfo.lastupdate = datetime.now()
systeminfo.initialinstall = datetime.now()
session.add(systeminfo)
session.commit()
def _db_V01_01():
"""
Legt die Tabelle 'kontakte' an. Bei bestehenden Datenbanken, die mit
Version 01.00 initialisiert wurden, fehlt diese Tabelle, da das
Kontakt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.01')
session.commit()
def _db_V01_02():
"""
Legt die Tabelle 'projekte' an. Bei bestehenden Datenbanken, die mit
Version 01.01 initialisiert wurden, fehlt diese Tabelle, da das
Projekt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.02')
session.commit()
def _db_V01_03():
"""
Legt die Tabellen 'kontaktadressen' und 'call_logs' an. Bei bestehenden
Datenbanken, die mit Version 01.02 initialisiert wurden, fehlen diese
Tabellen, da die Modelle erst nachträglich ergänzt wurden.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.03')
session.commit()
def _db_V01_04():
"""
Fügt die Spalte 'position' zur Tabelle 'kontaktadressen' hinzu.
Bei bestehenden Datenbanken, die mit Version 01.03 initialisiert wurden,
fehlt diese Spalte, da das Feld erst nachträglich ergänzt wurde.
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen.
"""
from sqlalchemy import text
# Prüfen, ob die Spalte bereits existiert (Neuinstallation via create_all)
result = session.execute(text("PRAGMA table_info(kontaktadressen)"))
spalten = {row[1] for row in result}
if 'position' not in spalten:
session.execute(text(
"ALTER TABLE kontaktadressen ADD COLUMN position VARCHAR(200)"
))
_updatesysteminfo('01.04')
session.commit()
def _db_V01_05():
"""
Legt Indizes auf den Fremdschlüsselspalten an, die für die
serverseitige Filterung der Listen-Endpunkte genutzt werden.
CREATE INDEX IF NOT EXISTS ist idempotent und überspringet bereits
vorhandene Indizes.
"""
from sqlalchemy import text
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontakte_kunden_id ON kontakte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_projekte_kunden_id ON projekte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontaktadressen_projekt_id ON kontaktadressen (projekt_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_call_logs_projekt_id ON call_logs (projekt_id)"))
_updatesysteminfo('01.05')
session.commit()
def _db_V01_07():
"""
Platzhalter für die fehlgeschlagene Migration V01.06. Die eigentliche
Normalisierung erfolgt in V01.08, da V01.06 bereits (fehlerhaft)
durchlief und die Systeminfo auf 01.06 aktualisierte.
"""
pass
def _db_V01_08():
"""
Normalisiert bestehende Datumswerte in den Spalten 'datum' und
'wiedervorlage_datum' (call_logs) sowie 'startdatum' (projekte) auf
das ISO-Format yyyy-MM-dd. Diese Spalten waren bisher String-Spalten
und können Werte ohne führende Nullen (z.B. '2026-08-3') enthalten,
die von SQLite's date()-Funktion nicht erkannt werden. Daher wird
printf('%04d-%02d-%02d', ...) zur Normalisierung verwendet, das auch
einstellige Monate/Tage korrekt auffüllt. Leere Strings werden zu
NULL konvertiert, da der Date-Typ keine leeren Strings akzeptiert.
Alle Updates werden vor dem Update der Systeminfo committet, um
'database is locked' zu vermeiden.
"""
from sqlalchemy import text
# Normalisierungs-Ausdruck: splittet den Datum-String am '-' und
# formatiert die Komponenten mit führenden Nullen.
# _norm(col) = printf('%04d-%02d-%02d',
# cast(substr(col, 1, instr(col, '-')-1) as integer),
# cast(substr(col, instr(col, '-')+1, instr(substr(col, instr(col, '-')+1), '-')-1) as integer),
# cast(substr(col, instr(col, '-')+instr(substr(col, instr(col, '-')+1), '-')+1) as integer)
# )
# Da dieser Ausdruck sehr lang ist, wird er pro Spalte inline gesetzt.
# call_logs.datum
session.execute(text(
"UPDATE call_logs SET datum = "
"printf('%04d-%02d-%02d', "
" cast(substr(datum, 1, instr(datum, '-')-1) as integer), "
" cast(substr(datum, instr(datum, '-')+1, "
" instr(substr(datum, instr(datum, '-')+1), '-')-1) as integer), "
" cast(substr(datum, "
" instr(datum, '-')+instr(substr(datum, instr(datum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE datum IS NOT NULL AND datum != '' "
"AND instr(datum, '-') > 0"
))
session.execute(text(
"UPDATE call_logs SET datum = NULL WHERE datum = ''"
))
# call_logs.wiedervorlage_datum
session.execute(text(
"UPDATE call_logs SET wiedervorlage_datum = "
"printf('%04d-%02d-%02d', "
" cast(substr(wiedervorlage_datum, 1, instr(wiedervorlage_datum, '-')-1) as integer), "
" cast(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1, "
" instr(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1), '-')-1) as integer), "
" cast(substr(wiedervorlage_datum, "
" instr(wiedervorlage_datum, '-')+instr(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE wiedervorlage_datum IS NOT NULL AND wiedervorlage_datum != '' "
"AND instr(wiedervorlage_datum, '-') > 0"
))
session.execute(text(
"UPDATE call_logs SET wiedervorlage_datum = NULL WHERE wiedervorlage_datum = ''"
))
# projekte.startdatum
session.execute(text(
"UPDATE projekte SET startdatum = "
"printf('%04d-%02d-%02d', "
" cast(substr(startdatum, 1, instr(startdatum, '-')-1) as integer), "
" cast(substr(startdatum, instr(startdatum, '-')+1, "
" instr(substr(startdatum, instr(startdatum, '-')+1), '-')-1) as integer), "
" cast(substr(startdatum, "
" instr(startdatum, '-')+instr(substr(startdatum, instr(startdatum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE startdatum IS NOT NULL AND startdatum != '' "
"AND instr(startdatum, '-') > 0"
))
session.execute(text(
"UPDATE projekte SET startdatum = NULL WHERE startdatum = ''"
))
# Daten-Updates committen, bevor die Systeminfo aktualisiert wird.
session.commit()
# Systeminfo erst nach dem Commit der Daten-Updates aktualisieren.
_updatesysteminfo('01.08')
session.commit()
def _db_V01_09():
"""
Legt die Tabelle 'projekt_users' an. Keine Initial-Befüllung —
bestehende Projekte bleiben ohne Zuordnungen, bis der AppAdmin
diese manuell vergibt. SysAdmin/AppAdmin sehen alle Projekte ohnehin.
create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.09')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
_db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist. Ist die
Datenbank noch leer (Tabelle fehlt), wird die Ersteinrichtung ausgeführt.
Bei einer Neuinstallation erzeugt _db_V01_00 über create_all bereits alle
Tabellen mit dem aktuellen Schema (inklusive aller später ergänzten Spalten,
Tabellen und Indizes). Die inkrementellen Migrationen laufen trotzdem
sequentiell nach — jede einzelne muss idempotent sein, damit sie auf der
frisch erzeugten DB fehlerfrei durchläuft (siehe Konvention in AGENTS.md).
"""
versionen = sorted(filter(lambda f: f.startswith("_db_V"), globals()))
curr_dbversion = '_db_V01_00'
try:
systeminfo: CrmSysteminfo = CrmSysteminfo.query.get(1)
curr_dbversion = "_db_V" + systeminfo.version.replace(".", "_")
except (OperationalError, AttributeError):
_db_V01_00()
for v in versionen:
if v > curr_dbversion:
globals()[v]()
print('########## crm Database updated')