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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in new issue
Block a user