""" 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 :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 _db_V01_10(): """ Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet. """ dbapp.create_all(bind_key=BINDKEY) _updatesysteminfo('01.10') session.commit() def _db_V01_11(): """ Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet (idempotent). """ dbapp.create_all(bind_key=BINDKEY) _updatesysteminfo('01.11') session.commit() def _db_V01_12(): """ Fügt der Tabelle 'tags' die drei neuen Spalten für die Telefon-Akquise-Workflow-Logik hinzu: - is_erledigend (BOOLEAN NOT NULL DEFAULT 0) - loest_wiedervorlage_aus (BOOLEAN NOT NULL DEFAULT 0) - wiedervorlage_abstand_tage (INTEGER) Idempotent: Bei Neuinstallation hat create_all die Spalten bereits angelegt — in diesem Fall wird das jeweilige ALTER TABLE übersprungen. """ from sqlalchemy import text result = session.execute(text("PRAGMA table_info(tags)")) spalten = {row[1] for row in result} if 'is_erledigend' not in spalten: session.execute(text( "ALTER TABLE tags ADD COLUMN is_erledigend BOOLEAN NOT NULL DEFAULT 0" )) if 'loest_wiedervorlage_aus' not in spalten: session.execute(text( "ALTER TABLE tags ADD COLUMN loest_wiedervorlage_aus BOOLEAN NOT NULL DEFAULT 0" )) if 'wiedervorlage_abstand_tage' not in spalten: session.execute(text( "ALTER TABLE tags ADD COLUMN wiedervorlage_abstand_tage INTEGER" )) _updatesysteminfo('01.12') 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')