Files
kbcrm/App_Server/root/crm/model/__init__.py
T
gg 5abcb2d96c feat(tags): Backend mit REST-API und Client-Anbindung fuer hierarchische Tags
- 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>
2026-09-19 14:08:34 +02:00

314 lines
12 KiB
Python

"""
SQLAlchemy-Datenmodelle für die CRM-Domain.
Alle Modelle verwenden den Bind-Key 'appdb' und liegen damit in der separaten
SQLite-Datenbank kbp_crm.db. Die Trennung zur Auth-Datenbank (Bind-Key 'auth')
erfolgt ausschließlich über __bind_key__.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-02
:version: 1.0
"""
import datetime
import decimal
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from db import dbapp
# Bind-Key für alle CRM-Modelle (entspricht dem Eintrag in app.config["SQLALCHEMY_BINDS"])
BINDKEY = 'appdb'
# App-Rollen (roleids, konsistent zu den Konstanten in auth.model). Die Rolle
# 'AppAdmin' ist bereits in auth.model definiert und wird von den CRM-Routes
# implizit über den Decorator berücksichtigt.
#
# Rollenmodell (additiv): Jeder User erhält zwingend die Basis-Rolle 'User'
# (MITARBEITER, entspricht APP_USER in auth.model). Damit passiert er das
# Login-Gate in auth/routes.checklogin. Zusätzliche Rollen wie 'KundenManager'
# werden ergänzend zugewiesen und gewähren weitergehende Berechtigungen, die
# über den Decorator authorisation_required geprüft werden.
MITARBEITER = "User"
KUNDEN_MANAGER = "KundenManager"
def columns2dict(record, excludefields=[]):
"""
Wandelt die Spalten eines SQLAlchemy-Records in ein Dictionary um.
Datums- und Decimal-Werte werden dabei in JSON-taugliche Strings konvertiert.
Über excludefields können Spalten ausgeschlossen werden (z.B. Passwort-Hashes).
"""
if not isinstance(excludefields, list):
excludefields = []
attribs = dict()
if hasattr(record, '__table__'):
if hasattr(record.__table__, 'columns'):
for col in record.__table__.columns:
if col.name in excludefields:
continue
value = getattr(record, col.name)
if isinstance(value, datetime.date):
value = value.isoformat()
if isinstance(value, decimal.Decimal):
value = "{0:.2f}".format(value)
attribs[col.name] = value
return attribs
class CrmSysteminfo(dbapp.Model):
"""
Systeminformationstabelle für die CRM-Datenbank.
Hält die DB-Versionsnummer, mit der checkdb feststellt, welche Migrationen
noch ausgeführt werden müssen. Der Klassenname unterscheidet sich bewusst
von auth.model.Systeminfo, da beide Klassen dieselbe SQLAlchemy-Registry
teilen und sich daher nicht namentlich doppeln dürfen.
"""
__tablename__ = 'systeminfo'
__bind_key__ = BINDKEY
id = Column(Integer, primary_key=True)
version = Column(String(20), nullable=False)
lastupdate = Column(DateTime, nullable=True)
initialinstall = Column(DateTime, nullable=True)
class Kunde(dbapp.Model):
"""
Kundenstamm: Verwaltung aller Kunden (Firmen oder Privatpersonen) mit den
relevanten Adressdaten.
"""
__tablename__ = 'kunden'
__bind_key__ = BINDKEY
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
strasse = Column(String(200), nullable=True)
plz = Column(String(20), nullable=True)
ort = Column(String(100), nullable=True)
land = Column(String(80), nullable=True)
def to_dict(self):
"""Liefert die Kundendaten als Dictionary (für JSON-Serialisierung)."""
return columns2dict(self)
class Kontakt(dbapp.Model):
"""
Ansprechpartner bei einem Kunden. Jeder Kontakt ist genau einem Kunden
zugeordnet (kunden_id). Der Kundenname (kunden_name) ist keine eigene
Spalte, sondern wird über die Relationship zu Kunde abgeleitet und nur
in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontakte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontakte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
notiz = Column(String(2000), nullable=True)
kunde = relationship('Kunde')
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
return result
class Projekt(dbapp.Model):
"""
Projekt eines Kunden. Jedes Projekt ist genau einem Kunden zugeordnet
(kunden_id). Der Kundenname (kunden_name) ist keine eigene Spalte,
sondern wird über die Relationship zu Kunde abgeleitet und nur in
to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'projekte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
projektname = Column(String(200), nullable=False)
zweck = Column(String(2000), nullable=True)
verantwortlicher = Column(String(200), nullable=True)
startdatum = Column(Date, nullable=True)
kunde = relationship('Kunde')
def to_dict(self):
"""
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
return result
class ProjektUser(dbapp.Model):
"""
Verknüpfungstabelle zwischen Projekt und User.
Steuert die Sichtbarkeit von Projekten: Ein Nutzer sieht nur Projekte,
denen er über diese Tabelle zugeordnet ist. Die Zuordnung erfolgt durch
den AppAdmin (oder später andere berechtigte Rollen).
Die Tabelle liegt in der CRM-Datenbank (appdb). Die Spalte 'user_id'
referenziert logisch die 'id' in der Auth-Datenbanktabelle 'users', ein
physischer Fremdschlüssel ist aufgrund der zwei getrennten SQLite-Datenbanken
nicht möglich. Die Auflösung von user_id auf username etc. erfolgt zur
Laufzeit durch eine separate Query gegen die Auth-Datenbank.
Das UniqueConstraint stellt sicher, dass jeder Nutzer nur einmal pro
Projekt zugeordnet werden kann.
"""
__tablename__ = 'projekt_users'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekt_users_projekt_id', 'projekt_id'),
UniqueConstraint('projekt_id', 'user_id', name='uq_projekt_user'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
user_id = Column(Integer, nullable=False)
projekt = relationship('Projekt')
def to_dict(self):
"""Liefert die Projekt-User-Zuordnung als Dictionary."""
return columns2dict(self)
class Kontaktadresse(dbapp.Model):
"""
Akquise-Ziel innerhalb eines Projekts. Jede Kontaktadresse ist genau
einem Projekt zugeordnet (projekt_id). Der Projektname (projekt_name)
ist keine eigene Spalte, sondern wird über die Relationship zu Projekt
abgeleitet und nur in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontaktadressen'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontaktadressen_projekt_id', 'projekt_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
firma = Column(String(200), nullable=True)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
strasse = Column(String(200), nullable=True)
plz = Column(String(20), nullable=True)
ort = Column(String(100), nullable=True)
land = Column(String(80), nullable=True)
notiz = Column(String(2000), nullable=True)
projekt = relationship('Projekt')
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
Projekt-Relationship eingefügt.
"""
result = columns2dict(self)
result['projekt_name'] = self.projekt.projektname if self.projekt else None
return result
class CallLog(dbapp.Model):
"""
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern
werden über die Projekt-Relationship abgeleitet und nur in to_dict()
für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'call_logs'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_call_logs_projekt_id', 'projekt_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
datum = Column(Date, nullable=True)
uhrzeit_beginn = Column(String(10), nullable=True)
dauer = Column(String(10), nullable=True)
betreff = Column(String(200), nullable=True)
name_ansprechpartner = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(Date, nullable=True)
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
def to_dict(self):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt.
"""
result = columns2dict(self)
if self.projekt:
result['projekt_name'] = self.projekt.projektname
result['kunden_name'] = self.projekt.kunde.name if self.projekt.kunde else None
else:
result['projekt_name'] = None
result['kunden_name'] = None
return result
class Tag(dbapp.Model):
"""
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
"""
__tablename__ = 'tags'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_tags_parent_id', 'parent_id'),
)
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
description = Column(String(2000), nullable=True)
color = Column(String(20), nullable=True)
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
is_parent = Column(Boolean, nullable=False, default=False)
# Selbst-Referenzierende Relationship: parent verweist auf den
# uebergeordneten Tag, children auf alle direkten Kinder. Die
# Sortierung der Kinder erfolgt alphabetisch nach name.
parent = relationship(
'Tag', remote_side='Tag.id', back_populates='children'
)
children = relationship(
'Tag', back_populates='parent', order_by='Tag.name'
)
def to_dict(self):
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
return columns2dict(self)
print("########## crm/model importiert")