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:
gg committed 2026-09-22 20:12:02 +02:00
1 parent 24b492c5c1
commit 977b64292c
9 files changed
+174 -18

No files matched your search

+57 -8
View File
@@ -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.
+31 -4
View File
@@ -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
+50
View File
@@ -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
+14 -2
View File
@@ -189,6 +189,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let currentLog: {
id: number
projekt_id: number
kontaktadresse_id: number
projekt_name: string
kunden_name: string
datum: string
@@ -203,6 +204,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
} = {
id: -1,
projekt_id: -1,
kontaktadresse_id: -1,
projekt_name: "",
kunden_name: "",
datum: "",
@@ -315,7 +317,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
fetchCallLogById(id).then((result) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e))
} else {
@@ -332,7 +334,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true")
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
// Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
// Maske aus der Kontaktadressenliste geöffnet wurde.
currentLog.kontaktadresse_id = routeParams && routeParams.kontaktadresse_id ? parseInt(routeParams.kontaktadresse_id) : -1
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
startZeit = new Date()
timerId = window.setInterval(() => {
@@ -386,6 +391,12 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
projekt_id: projektVoreingestellt
? currentLog.projekt_id
: parseInt((formData.get("projekt_id_select") as string) || "-1"),
// Pflichtfeld: voreingestellt aus dem currentLog, sonst
// aus dem Select-Widget (kontaktadresse_id ist im Server
// ein Pflichtfeld, ohne wird Bad Request geliefert).
kontaktadresse_id: ansprechpartnerVoreingestellt
? currentLog.kontaktadresse_id
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
projekt_name: "",
kunden_name: "",
datum: currentLog.datum || "",
@@ -501,6 +512,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name: "kontaktadresse_id",
value: -1,
options: kontaktadressenOptionen,
required: true,
onchanged: (kontaktadresseId: number) => {
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) {
+5 -1
View File
@@ -63,6 +63,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
id: -1,
projekt_id: kontaktadresse.projekt_id,
projekt_name: kontaktadresse.projekt_name,
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
kontaktadresse_id: kontaktadresse.id,
datum: datum,
uhrzeit_beginn: uhrzeit,
dauer: "",
@@ -246,7 +248,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
plz: (formData.get("plz") as string) || "",
ort: (formData.get("ort") as string) || "",
land: (formData.get("land") as string) || "",
notiz: (formData.get("notiz") as string) || ""
notiz: (formData.get("notiz") as string) || "",
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
is_erledigt: false
}
if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
+3 -1
View File
@@ -239,7 +239,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
projektname: (formData.get("projektname") as string) || "",
zweck: (formData.get("zweck") as string) || "",
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
startdatum: (formData.get("startdatum") as string) || ""
startdatum: (formData.get("startdatum") as string) || "",
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
is_erledigt: false
}
if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
+3
View File
@@ -6,12 +6,15 @@ import {iTag} from "./tag"
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
* Kontaktadresse (Fremdschlüssel auf kontaktadressen.id).
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags.
*/
export interface iCallLog {
id: number
projekt_id: number
kontaktadresse_id: number
projekt_name: string
kunden_name: string
datum: string
+6 -1
View File
@@ -5,7 +5,10 @@ import m from "mithril"
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Projekt-Relationship abgeleitet und im JSON ergänzt.
* Projekt-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
* True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag mit
* is_erledigend=True hat.
*/
export interface iKontaktadresse {
id: number
@@ -22,6 +25,8 @@ export interface iKontaktadresse {
ort: string
land: string
notiz: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */
+5 -1
View File
@@ -4,7 +4,9 @@ import m from "mithril"
* Datenmodell für ein Projekt.
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Kunde-Relationship abgeleitet und im JSON ergänzt.
* Kunde-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
* True, wenn das Projekt Kontaktadressen hat und alle davon erledigt sind.
*/
export interface iProjekt {
id: number
@@ -14,6 +16,8 @@ export interface iProjekt {
zweck: string
verantwortlicher: string
startdatum: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */