- SQLAlchemy-Modell Tag mit Selbst-Referenz (parent_id), is_parent-Flag - DB-Migration V01.10 (idempotent via create_all) - Geschaeftslogik tag.py: CRUD, Hierarchie-Aufbau, kaskadierendes Loeschen, Zirkelbezug-Pruefung - REST-Endpunkte /xhr/tag/*: Lesen fuer Mitarbeiter, Schreiben fuer AppAdmin - Client-Modell tag.ts mit REST-Funktionen - Frontend tags.ts auf REST-API umgestellt, hartcodierte Daten entfernt, Loesch-Dialog mit kaskadierender Bestaetigung Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
260 lines
9.6 KiB
Python
260 lines
9.6 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 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')
|