- Migration V01_13: Spalte kontaktadresse_id auf call_logs mit Backfill über die erste Kontaktadresse des Projekts; Commit vor dem Systeminfo-Update (Muster wie V01_08) gegen 'database is locked'. - CallLog gehört zu genau einer Kontaktadresse (FK, Pflichtfeld); create/update validieren Existenz und Projekt-Zugehörigkeit. - is_erledigt() auf Kontaktadresse (mind. ein erledigend-Tag reicht) und Projekt (False ohne Kontakte, sonst alle erledigt); beide in to_dict() serialisiert. - Client: kontaktadresse_id in Notizmaske/Submit (required), Interfaces um is_erledigt erweitert. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
353 lines
13 KiB
Python
353 lines
13 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 _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 _db_V01_13():
|
|
"""
|
|
Fügt der Tabelle 'call_logs' die Spalte 'kontaktadresse_id' hinzu:
|
|
Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
|
|
(Fremdschlüssel -> kontaktadressen.id, Pflichtfeld).
|
|
|
|
Da SQLite kein nachträgliches NOT NULL auf einer bestehenden Spalte
|
|
setzen kann, wird die Spalte als nullable angelegt und anschließend
|
|
backgefüllt: Für bestehende CallLogs ohne Kontakt-Bezug wird die
|
|
erste Kontaktadresse des gleichen Projekts eingetragen. Das NOT NULL
|
|
ist im Modell deklariert und wird über die Server-Validierung in
|
|
crm.model.call_log durchgesetzt.
|
|
|
|
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
|
|
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
|
|
Backfill und der Index sind ebenfalls idempotent.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
result = session.execute(text("PRAGMA table_info(call_logs)"))
|
|
spalten = {row[1] for row in result}
|
|
if 'kontaktadresse_id' not in spalten:
|
|
session.execute(text(
|
|
"ALTER TABLE call_logs ADD COLUMN kontaktadresse_id INTEGER"
|
|
))
|
|
# Backfill: erste Kontaktadresse des Projekts eintragen, falls
|
|
# noch keine kontaktadresse_id gesetzt ist.
|
|
session.execute(text(
|
|
"UPDATE call_logs SET kontaktadresse_id = ("
|
|
" SELECT ka.id FROM kontaktadressen ka"
|
|
" WHERE ka.projekt_id = call_logs.projekt_id"
|
|
" LIMIT 1"
|
|
") WHERE kontaktadresse_id IS NULL"
|
|
))
|
|
session.execute(text(
|
|
"CREATE INDEX IF NOT EXISTS ix_call_logs_kontaktadresse_id"
|
|
" ON call_logs (kontaktadresse_id)"
|
|
))
|
|
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
|
|
# (dasselbe Muster wie _db_V01_08): Das Backfill-UPDATE öffnet eine
|
|
# Schreib-Transaktion auf der Default-Bind-Verbindung —
|
|
# SQLALCHEMY_DATABASE_URI zeigt auf dieselbe Datei wie der appdb-Bind.
|
|
# Das ORM-Update der Systeminfo läuft über die appdb-Bind-Verbindung;
|
|
# ohne dieses Commit blockiert die offene Transaktion das UPDATE
|
|
# ('database is locked').
|
|
session.commit()
|
|
_updatesysteminfo('01.13')
|
|
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')
|