Files
kbcrm/App_Server/root/crm/model/checkdb.py
T
gg 6e3cc7375d feat(workflow): Erledigt-Feld entfernen und Tages-Workflow-Maske überarbeiten
Das redundante 'erledigt'-Feld wird aus CallLog entfernt (Modell, API,
Client, Migration _db_V01_15 DROP COLUMN). Der Erledigt-Status ergibt
sich dynamisch aus den zugeordneten Tags (is_erledigend).

Die Tages-Workflow-Maske zeigt Datum, Uhrzeit Beginn und Dauer der neuen
Notiz als Read-Only-Panel; die Dauer läuft als Timer (mm:ss) mit. Ein
Betreff-Feld ergänzt die neue Notiz. Mit flexContent wird der
Formular-Inhalt als Flex-Spalte gerendert, das Historie-Panel füllt den
verbleibenden Platz und scrollt in sich.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 10:18:09 +02:00

428 lines
16 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 _db_V01_14():
"""
Fügt der Tabelle 'call_logs' die Spalte 'user_id' hinzu: Jede
Gesprächsnotiz erfasst, welcher Mitarbeiter den Anruf getätigt hat.
Die Spalte referenziert logisch die 'id' in der Auth-Datenbanktabelle
'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
getrennten SQLite-Datenbanken nicht möglich (Muster wie
projekt_users.user_id).
Kein Backfill: Für bestehende CallLogs ist der Ersteller nicht mehr
ermittelbar — die Spalte bleibt dort NULL (Anzeige 'unbekannt').
Neue Notizen erhalten die user_id serverseitig über current_user.id
(siehe crm.routes.calllog_create).
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
Index ist über IF NOT EXISTS ebenfalls idempotent.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'user_id' not in spalten:
session.execute(text(
"ALTER TABLE call_logs ADD COLUMN user_id INTEGER"
))
session.execute(text(
"CREATE INDEX IF NOT EXISTS ix_call_logs_user_id"
" ON call_logs (user_id)"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 und _db_V01_13): Die DDL-Anweisung
# läuft über die 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.14')
session.commit()
def _db_V01_15():
"""
Entfernt die Spalte 'erledigt' aus der Tabelle 'call_logs'. Der
Erledigt-Status einer Gesprächsnotiz ist redundante Information:
Er ergibt sich dynamisch daraus, ob einer der zugeordneten Tags
is_erledigend=True hat (Kontaktadresse.is_erledigt()). Das Feld
wurde aus Modell und Client entfernt.
SQLite unterstützt DROP COLUMN ab Version 3.35; die Laufzeitumgebung
liefert 3.40.1.
Idempotent: Bei Neuinstallation existiert die Spalte nicht mehr
(create_all legt sie nicht mehr an) — der PRAGMA-Check überspringt
das DROP COLUMN in diesem Fall.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'erledigt' in spalten:
session.execute(text(
"ALTER TABLE call_logs DROP COLUMN erledigt"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 bis _db_V01_14): Die DDL-Anweisung
# läuft über die 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.15')
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')