""" 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') 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")