Backend: - Neues CallLogTag-Model (Tabelle call_logs_tags) mit Relationship auf CallLog (cascade delete-orphan); to_dict liefert 'tags'. - Migration _db_V01_11 (idempotent via create_all). - _set_call_log_tags in create/update_call_log: ersetzt Tag-Zuordnungen, filtert is_parent-Tags, eliminiert Duplikate. - _parse_date wandelt ISO-Strings in date-Objekte (fixt autoflush- Crash bei Date-Spalten). Frontend: - iCallLog um tags (Lesen) und tag_ids (Schreiben) ergänzt. - Neues generisches MultiSelect-Widget (widgets/select.ts): Badges + Edit-Button, inline Modal mit Suchfeld, Checkbox-Liste, OK-Button; konfigurierbare Texte (placeholder, modalTitle, editTooltip). - Call_Log-Formular: Tag-Auswahl via MultiSelect, Submit via FormData.getAll; Tag-Badges in der Listenansicht hinter dem Betreff. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
345 lines
13 KiB
Python
345 lines
13 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')
|
|
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
|
|
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
|
|
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
|
|
tags_rel = relationship(
|
|
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
|
|
)
|
|
|
|
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. Die zugeordneten
|
|
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
|
|
"""
|
|
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
|
|
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
|
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
|
return result
|
|
|
|
|
|
class CallLogTag(dbapp.Model):
|
|
"""
|
|
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
|
|
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
|
|
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
|
|
Gesprächsnotiz zugeordnet werden kann.
|
|
"""
|
|
__tablename__ = 'call_logs_tags'
|
|
__bind_key__ = BINDKEY
|
|
__table_args__ = (
|
|
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
|
|
Index('ix_calllogtag_call_log_id', 'call_log_id'),
|
|
Index('ix_calllogtag_tag_id', 'tag_id'),
|
|
)
|
|
id = Column(Integer, primary_key=True)
|
|
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
|
|
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
|
|
|
|
call_log = relationship('CallLog', back_populates='tags_rel')
|
|
tag = relationship('Tag')
|
|
|
|
|
|
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")
|