diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index 6b7eeaf..3c53156 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -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. diff --git a/App_Server/root/crm/model/call_log.py b/App_Server/root/crm/model/call_log.py index ceaa2f7..712df8a 100644 --- a/App_Server/root/crm/model/call_log.py +++ b/App_Server/root/crm/model/call_log.py @@ -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 diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index d4f1c0a..da736de 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -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 diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index dfb4c52..b078cac 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -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) { diff --git a/Client/Components/kontaktadresse.ts b/Client/Components/kontaktadresse.ts index 35b52ff..274e896 100644 --- a/Client/Components/kontaktadresse.ts +++ b/Client/Components/kontaktadresse.ts @@ -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() }) diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts index 865de5a..59cd78c 100644 --- a/Client/Components/projekt.ts +++ b/Client/Components/projekt.ts @@ -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() }) diff --git a/Client/models/call_log.ts b/Client/models/call_log.ts index 42ecd35..70f5cb3 100644 --- a/Client/models/call_log.ts +++ b/Client/models/call_log.ts @@ -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 diff --git a/Client/models/kontaktadresse.ts b/Client/models/kontaktadresse.ts index c6b1884..3bc54e8 100644 --- a/Client/models/kontaktadresse.ts +++ b/Client/models/kontaktadresse.ts @@ -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 } /* ------------------------------------------------------------------ */ diff --git a/Client/models/projekt.ts b/Client/models/projekt.ts index 0dd60fe..62d59b6 100644 --- a/Client/models/projekt.ts +++ b/Client/models/projekt.ts @@ -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 } /* ------------------------------------------------------------------ */