feat(calllog): kontaktadresse_id als Pflicht-FK + is_erledigt()-Methoden
- Migration V01_13: Spalte kontaktadresse_id auf call_logs mit Backfill über die erste Kontaktadresse des Projekts; Commit vor dem Systeminfo-Update (Muster wie V01_08) gegen 'database is locked'. - CallLog gehört zu genau einer Kontaktadresse (FK, Pflichtfeld); create/update validieren Existenz und Projekt-Zugehörigkeit. - is_erledigt() auf Kontaktadresse (mind. ein erledigend-Tag reicht) und Projekt (False ohne Kontakte, sonst alle erledigt); beide in to_dict() serialisiert. - Client: kontaktadresse_id in Notizmaske/Submit (required), Interfaces um is_erledigt erweitert. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
24b492c5c1
commit
977b64292c
9 files changed
+174
-18
No files matched your search
@@ -148,15 +148,33 @@ class Projekt(dbapp.Model):
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -220,34 +238,64 @@ class Kontaktadresse(dbapp.Model):
|
||||
land = Column(String(80), nullable=True)
|
||||
notiz = Column(String(2000), nullable=True)
|
||||
|
||||
projekt = relationship('Projekt')
|
||||
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 wird zusätzlich aus der
|
||||
Projekt-Relationship eingefügt.
|
||||
Projekt-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['is_erledigt'] = self.is_erledigt()
|
||||
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.
|
||||
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)
|
||||
datum = Column(Date, nullable=True)
|
||||
uhrzeit_beginn = Column(String(10), nullable=True)
|
||||
dauer = Column(String(10), nullable=True)
|
||||
@@ -259,6 +307,7 @@ class CallLog(dbapp.Model):
|
||||
erledigt = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
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.
|
||||
|
||||
@@ -12,7 +12,7 @@ dort über den Decorator authorisation_required.
|
||||
from datetime import date
|
||||
|
||||
from db import dbapp
|
||||
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
|
||||
from crm.model import CallLog, CallLogTag, Kontaktadresse, Projekt, ProjektUser, Tag
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
@@ -71,6 +71,29 @@ def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
|
||||
|
||||
|
||||
|
||||
def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
|
||||
"""
|
||||
Liest kontaktadresse_id aus dem data-Dict und validiert sie:
|
||||
- Pflichtfeld: fehlt sie oder ist sie leer, wird Bad Request geliefert.
|
||||
- Die Kontaktadresse muss existieren und zum selben Projekt gehören.
|
||||
|
||||
Liefert die geprüfte ID als int zurück.
|
||||
"""
|
||||
kontaktadresse_id = data.get("kontaktadresse_id")
|
||||
if not kontaktadresse_id:
|
||||
raise ApplicationError(
|
||||
"Es muss eine Kontaktadresse ausgewählt werden.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse = Kontaktadresse.query.get(int(kontaktadresse_id))
|
||||
if kontaktadresse is None or kontaktadresse.projekt_id != int(projekt_id):
|
||||
raise ApplicationError(
|
||||
"Die ausgewählte Kontaktadresse existiert nicht oder gehört nicht zum Projekt.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
return kontaktadresse.id
|
||||
|
||||
|
||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
||||
"""
|
||||
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
||||
@@ -130,9 +153,9 @@ def create_call_log(data: dict) -> CallLog:
|
||||
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
|
||||
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
||||
|
||||
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer,
|
||||
betreff, name_ansprechpartner, telefon, notiz,
|
||||
wiedervorlage_datum, erledigt
|
||||
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
|
||||
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
|
||||
notiz, wiedervorlage_datum, erledigt, tag_ids
|
||||
"""
|
||||
projekt_id = data.get("projekt_id")
|
||||
if projekt_id is None:
|
||||
@@ -146,8 +169,10 @@ def create_call_log(data: dict) -> CallLog:
|
||||
"Das ausgewählte Projekt existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||
call_log = CallLog()
|
||||
call_log.projekt_id = int(projekt_id)
|
||||
call_log.kontaktadresse_id = kontaktadresse_id
|
||||
call_log.datum = _parse_date(data.get("datum"))
|
||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
@@ -181,7 +206,9 @@ def update_call_log(data: dict) -> CallLog:
|
||||
"Das ausgewählte Projekt existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||
call_log.projekt_id = int(projekt_id)
|
||||
call_log.kontaktadresse_id = kontaktadresse_id
|
||||
call_log.datum = _parse_date(data.get("datum"))
|
||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
|
||||
@@ -275,6 +275,56 @@ def _db_V01_12():
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_13():
|
||||
"""
|
||||
Fügt der Tabelle 'call_logs' die Spalte 'kontaktadresse_id' hinzu:
|
||||
Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
|
||||
(Fremdschlüssel -> kontaktadressen.id, Pflichtfeld).
|
||||
|
||||
Da SQLite kein nachträgliches NOT NULL auf einer bestehenden Spalte
|
||||
setzen kann, wird die Spalte als nullable angelegt und anschließend
|
||||
backgefüllt: Für bestehende CallLogs ohne Kontakt-Bezug wird die
|
||||
erste Kontaktadresse des gleichen Projekts eingetragen. Das NOT NULL
|
||||
ist im Modell deklariert und wird über die Server-Validierung in
|
||||
crm.model.call_log durchgesetzt.
|
||||
|
||||
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
|
||||
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
|
||||
Backfill und der Index sind ebenfalls idempotent.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
result = session.execute(text("PRAGMA table_info(call_logs)"))
|
||||
spalten = {row[1] for row in result}
|
||||
if 'kontaktadresse_id' not in spalten:
|
||||
session.execute(text(
|
||||
"ALTER TABLE call_logs ADD COLUMN kontaktadresse_id INTEGER"
|
||||
))
|
||||
# Backfill: erste Kontaktadresse des Projekts eintragen, falls
|
||||
# noch keine kontaktadresse_id gesetzt ist.
|
||||
session.execute(text(
|
||||
"UPDATE call_logs SET kontaktadresse_id = ("
|
||||
" SELECT ka.id FROM kontaktadressen ka"
|
||||
" WHERE ka.projekt_id = call_logs.projekt_id"
|
||||
" LIMIT 1"
|
||||
") WHERE kontaktadresse_id IS NULL"
|
||||
))
|
||||
session.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_call_logs_kontaktadresse_id"
|
||||
" ON call_logs (kontaktadresse_id)"
|
||||
))
|
||||
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
|
||||
# (dasselbe Muster wie _db_V01_08): Das Backfill-UPDATE öffnet eine
|
||||
# Schreib-Transaktion auf der Default-Bind-Verbindung —
|
||||
# SQLALCHEMY_DATABASE_URI zeigt auf dieselbe Datei wie der appdb-Bind.
|
||||
# Das ORM-Update der Systeminfo läuft über die appdb-Bind-Verbindung;
|
||||
# ohne dieses Commit blockiert die offene Transaktion das UPDATE
|
||||
# ('database is locked').
|
||||
session.commit()
|
||||
_updatesysteminfo('01.13')
|
||||
session.commit()
|
||||
|
||||
|
||||
def checkupdates():
|
||||
"""
|
||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||
|
||||
Reference in new issue
Block a user