""" 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 :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') # Kontaktadressen dieses Projekts (Akquise-Ziele). Die Rückrichtung # ist Kontaktadresse.projekt (back_populates). kontaktadressen = relationship('Kontaktadresse', back_populates='projekt') def is_erledigt(self): """ Prüft dynamisch, ob das Projekt als erledigt gilt. True, wenn das Projekt Kontaktadressen hat und alle davon erledigt sind. Ein Projekt ohne Kontaktadressen ist offen (False), da Kontaktadressen erst nach dem Anlegen des Projekts erfasst werden können — ein neues Projekt ist somit zunächst offen. """ if not self.kontaktadressen: return False return all(k.is_erledigt() for k in self.kontaktadressen) 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. is_erledigt wird dynamisch aus den zugeordneten Kontaktadressen berechnet. """ result = columns2dict(self) result['kunden_name'] = self.kunde.name if self.kunde else None result['is_erledigt'] = self.is_erledigt() 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', back_populates='kontaktadressen') # Gesprächsnotizen, die zu diesem Kontakt erfasst wurden. Die # Rückrichtung ist CallLog.kontaktadresse (back_populates); die # Join-Bedingung ergibt sich aus dem Fremdschlüssel # call_logs.kontaktadresse_id -> kontaktadressen.id. call_logs = relationship('CallLog', back_populates='kontaktadresse') def is_erledigt(self): """ Prüft dynamisch, ob der Kontakt als erledigt gilt. True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat. Spätere Notizen ohne erledigend-Tag setzen den Status nicht zurück. False bei keinen Notizen oder wenn keine Notiz einen erledigend-Tag hat. """ return any( clt.tag.is_erledigend for notiz in self.call_logs for clt in notiz.tags_rel ) def to_dict(self): """ Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden über columns2dict serialisiert; projekt_name, projekt_zweck und kunden_name werden zusätzlich aus der Projekt- bzw. Kunde-Relationship eingefügt. is_erledigt wird dynamisch aus den Gesprächsnotizen des Kontakts berechnet. """ result = columns2dict(self) result['projekt_name'] = self.projekt.projektname if self.projekt else None result['projekt_zweck'] = self.projekt.zweck if self.projekt else None result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None result['is_erledigt'] = self.is_erledigt() return result class CallLog(dbapp.Model): """ Gesprächsnotiz für einen Kontakt. Jede Gesprächsnotiz ist genau einem Projekt (projekt_id) und genau einer Kontaktadresse (kontaktadresse_id) zugeordnet. 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'), Index('ix_call_logs_kontaktadresse_id', 'kontaktadresse_id'), ) id = Column(Integer, primary_key=True) projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False) # Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse # (Pflichtfeld). nullable=False im Modell; in bestehenden Datenbanken # ist die physische Spalte nullable (SQLite kann kein nachträgliches # NOT NULL) — die Pflicht wird über die Server-Validierung in # crm.model.call_log durchgesetzt. kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False) # Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt # hat). 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). nullable=True, da Alt-Notizen (angelegt # vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung # auf den username erfolgt zur Laufzeit über eine separate Query. user_id = Column(Integer, nullable=True) 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) projekt = relationship('Projekt') kontaktadresse = relationship('Kontaktadresse', back_populates='call_logs') # 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. mitarbeiter_name löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen ohne erfassten Ersteller). 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 # Ersteller gegen die Auth-Datenbank auflösen (Muster wie # crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None. result['mitarbeiter_name'] = None if self.user_id is not None: from auth.model import User user = User.query.get(self.user_id) if user is not None: result['mitarbeiter_name'] = user.username # 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) # Felder für die Telefon-Akquise-Workflow-Logik: # is_erledigend: True = Kontakt gilt als erledigt, wenn dieser Tag # zugewiesen wird. # loest_wiedervorlage_aus: True = Tag löst eine Wiedervorlage aus. # wiedervorlage_abstand_tage: Tage bis zur Wiedervorlage (nur relevant, # wenn loest_wiedervorlage_aus True ist). is_erledigend = Column(Boolean, nullable=False, default=False) loest_wiedervorlage_aus = Column(Boolean, nullable=False, default=False) wiedervorlage_abstand_tage = Column(Integer, nullable=True) # 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")