From b6087a5b21e8546c67a05514170b9e1526eda09d Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 19 Jun 2026 06:28:58 +0200 Subject: [PATCH 01/74] fix `alembic` script --- ...added_new_column_and_renamed_table_for_.py | 58 +------------------ 1 file changed, 2 insertions(+), 56 deletions(-) diff --git a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py index 4ec9390..dc4e595 100644 --- a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py +++ b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py @@ -32,60 +32,6 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "grunderfassung_unternehmen", - sa.Column("erfassung_id", sa.INTEGER(), nullable=False), - sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), - sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), - sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), - sa.Column("Arbeitserfahrung", sa.TEXT(), nullable=True), - sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), - sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), - sa.Column("HoehereBildung", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_adresse", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_anrede_anschrift", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_email", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_festnetznummer", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_funktion_beziehung", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_mobilfunknummer", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_name", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_name_partner", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_titel", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_vorname", sa.TEXT(), nullable=True), - sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True), - sa.Column("Partnersuche__person_suche", sa.INTEGER(), nullable=True), - sa.Column("Partnersuche__un_suche", sa.INTEGER(), nullable=True), - sa.Column("Projektrelevanz__relevanz", sa.TEXT(), nullable=True), - sa.Column("Projektrelevanz__foerderperiode", sa.TEXT(), nullable=True), - sa.Column("Schulbildung", sa.TEXT(), nullable=True), - sa.Column("Sprachkenntnisse", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__PLZ", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anrede_anschrift", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anzahl_kinder__alter", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.INTEGER(), nullable=True), - sa.Column("Stammdaten__aufenthaltsort", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__bundesland", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__land", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__email", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__familienstand", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__festnetznummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__geburtsdatum", sa.DATE(), nullable=True), - sa.Column("Stammdaten__hausnummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__herkunftsland", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__mobilfunknummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__name", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__ort", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__rueckkehrer", sa.BOOLEAN(), nullable=True), - sa.Column("Stammdaten__staatsangehoerigkeit", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__strasse", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__titel", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__vorname", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_arbeitsstatus", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_deutsch_sprache", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.DATE(), nullable=True), - sa.Column("WeitereInfos__WI_meldung_institution", sa.TEXT(), nullable=True), - sa.PrimaryKeyConstraint("erfassung_id"), - ) - op.drop_table("grunderfassung") + op.drop_column("grunderfassung", "Metadaten_wiedereintrittsdatum") + op.rename_table("grunderfassung", "grunderfassung_unternehmen") # ### end Alembic commands ### -- 2.34.1 From b7f45066f052b6bd1e53aacb165aeb3eeb6940b7 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 25 Jun 2026 10:41:13 +0200 Subject: [PATCH 02/74] draft for company profile --- src/wce_crm/backend/backend.py | 21 +- src/wce_crm/form_defs.py | 3 +- src/wce_crm/gui.py | 405 ++++++++++++++++++++++++++++++++- 3 files changed, 417 insertions(+), 12 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index fff4103..3ccadf3 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -221,6 +221,8 @@ def front_get_company_list() -> list[FrontpageCompany]: stmt = sql.select( db.grunderfassung.c.erfassung_id, db.grunderfassung.c.Partnersuche__un_suche, + db.grunderfassung.c.Stammdaten__vorname, + db.grunderfassung.c.Stammdaten__name, db.grunderfassung.c.Metadaten_aktualisierung, ).order_by(db.grunderfassung.c.Metadaten_aktualisierung.desc()) @@ -229,20 +231,25 @@ def front_get_company_list() -> list[FrontpageCompany]: front_page_companies: list[FrontpageCompany] = [] - for entry in res: - erfassung_id = entry[0] - ma_id = entry[1] - datetime_akt = cast(datetime.datetime, entry[2]) + for entry in res.mappings(): + erfassung_id = entry["erfassung_id"] + ma_id = entry["Partnersuche__un_suche"] + datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) datetime_akt = datetime_akt.astimezone() - name: str = "PLATZHALTER INDIVIDUAL" - is_company: bool = False + name: str + is_company: bool if ma_id is not None: comp_info = initrec_comp_search_get_info(ma_id) name = comp_info["ma_unternehmensname"] is_company = True + else: + surname: str = entry["Stammdaten__name"] + first_name: str = entry["Stammdaten__vorname"] + names_to_join = [n for n in (first_name, surname) if n] + name = " ".join(names_to_join) + is_company = False - # front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt)) front_page_companies.append( FrontpageCompany( erfassung_id=erfassung_id, diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 365827c..3986bbc 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -323,12 +323,13 @@ INITREC_COMP_SEARCH_HEAD = [ ] INITREC_CONTACT_PERSON = [ + # TODO check removal FormField( "Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)", FormFieldType.TEXT, key="KP_name_partner", required=False, - placeholder="Text wird nach gewähltem Unternehmen angezeigt", + placeholder="[TODO: Logik in Verbindung mit Unternehmen unklar]", readonly=True, ), FormField( diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index b5ebef9..2453b64 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -29,6 +29,7 @@ from PySide6.QtCore import ( ) from PySide6.QtGui import QAction from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, QComboBox, QCompleter, @@ -38,6 +39,7 @@ from PySide6.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLayout, QLineEdit, @@ -48,6 +50,8 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QStackedWidget, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -2045,17 +2049,22 @@ class ClickableCell(QFrame): class HeaderCell(QLabel): - def __init__(self, text): + def __init__( + self, + text: str, + font_size: int = 12, + ): super().__init__(text) self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setStyleSheet(""" - HeaderCell { + self.setStyleSheet(f""" + HeaderCell {{ background-color: #e2e8f0; color: #475569; font-weight: bold; padding: 10px; border-radius: 6px; - } + font-size: {font_size}px; + }} """) @@ -2139,6 +2148,7 @@ class Page_InitRecCompany(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) + company_profile_requested = Signal() def __init__(self): super().__init__() @@ -2161,6 +2171,12 @@ class Page_InitRecCompany(QWidget): header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) + comp_profile_btn = QPushButton("Unternehmensprofil") + comp_profile_btn.clicked.connect(lambda: self.company_profile_requested.emit()) + comp_profile_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + comp_profile_btn.setMinimumWidth(250) + # comp_profile_btn.setMaximumWidth(200) + back_btn_main = QPushButton("← Zurück zur Übersicht") back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) @@ -2176,6 +2192,8 @@ class Page_InitRecCompany(QWidget): title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) + header_layout.addWidget(comp_profile_btn) + header_layout.addSpacing(15) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) @@ -2393,6 +2411,376 @@ class Page_InitRecPerson(QWidget): self.auto_form.reset_form() +class Page_CompanyProfile(QWidget): + back_main_requested = Signal() # back to main page + back_requested = Signal() # back button + # TODO update should trigger something else + update_triggered = Signal() # form saved (data changed for front page) + + def __init__(self): + super().__init__() + # Hauptlayout der Seite + outer_layout = QHBoxLayout(self) + vert_layout = QVBoxLayout() + # main_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.addStretch(1) + outer_layout.addLayout(vert_layout, stretch=100) + # outer_layout.addWidget(scroll_area, stretch=100) + outer_layout.addStretch(1) + # Optional: Damit der Container oben am Rand klebt + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # --- HEADER --- + header_container = QWidget() + header_container.setMinimumWidth(700) + header_container.setMaximumWidth(1200) + header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + header_layout = QVBoxLayout(header_container) + header_layout.setContentsMargins(0, 0, 0, 10) + + back_btn_main = QPushButton("← Zurück zur Übersicht") + back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) + back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_main.setMinimumWidth(200) + back_btn_main.setMaximumWidth(200) + back_btn_step = QPushButton("← Zurück") + back_btn_step.clicked.connect(lambda: self.back_requested.emit()) + back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_step.setMinimumWidth(200) + back_btn_step.setMaximumWidth(200) + + title = QLabel("Unternehmensprofil") + title.setStyleSheet("font-size: 20px; font-weight: bold;") + + header_layout.setSpacing(5) + + header_layout.addSpacing(15) + header_layout.addWidget(back_btn_step) + header_layout.addWidget(back_btn_main) + header_layout.addWidget(title) + vert_layout.addWidget(header_container) + + # --- MAIN CONTENT --- + container = QWidget() + # SCROLL AREA + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1200) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # optional: remove frame to look more modern + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + vert_layout.addWidget(scroll_area) + + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + + # --- MAIN LAYOUT --- + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator1) + + layout_change_title = QHBoxLayout() + self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...") + layout_change_title.addWidget(self.change_title_text) + change_title_btn = QPushButton("ändern") + layout_change_title.addWidget(change_title_btn) + change_title_btn.clicked.connect(self.set_company_name) + container_layout.addLayout(layout_change_title) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator2) + + container_layout.addSpacing(30) + + self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) + container_layout.addWidget(self.title_company_name) + + container_layout.addSpacing(20) + + # separation in left and right + main_hor_layout = QHBoxLayout() + main_hor_layout.setSpacing(20) + # main_hor_layout.setContentsMargins(20, 20, 20, 20) + + # left column + left_frame = QFrame() + left_frame.setObjectName("Segment") + left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + left_layout = QVBoxLayout(left_frame) + main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) + master_data_label = HeaderCell("Stammdaten & Vermerke") + master_data_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + left_layout.addWidget(master_data_label) + # self.table_master_data = MasterDataTable() + self.table_master_data = MasterDataFormWidget() + left_layout.addWidget(self.table_master_data) + left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) + + # right column + right_column_layout = QVBoxLayout() + right_column_layout.setSpacing(20) + main_hor_layout.addLayout(right_column_layout, stretch=1) + + # top right area + top_right_frame = QFrame() + top_right_frame.setObjectName("Segment") + top_right_frame.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + top_right_layout = QVBoxLayout(top_right_frame) + common_consultancy_label = HeaderCell("Pauschalberatung") + common_consultancy_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + top_right_layout.addWidget(common_consultancy_label) + top_right_new_btn = QPushButton("+ Hinzufügen") + top_right_new_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + top_right_new_btn.setMinimumHeight(30) + top_right_layout.addWidget(top_right_new_btn) + + # bottom right area + bottom_right_frame = QFrame() + bottom_right_frame.setObjectName("Segment") + bottom_right_frame.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + bottom_right_layout = QVBoxLayout(bottom_right_frame) + individual_consultancy_label = HeaderCell("Individualberatung") + individual_consultancy_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + bottom_right_layout.addWidget(individual_consultancy_label) + bottom_right_new_btn = QPushButton("+ Hinzufügen") + bottom_right_new_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + bottom_right_new_btn.setMinimumHeight(30) + bottom_right_layout.addWidget(bottom_right_new_btn) + + right_column_layout.addWidget(top_right_frame) + right_column_layout.addWidget(bottom_right_frame) + right_column_layout.addStretch(1) + + container_layout.addLayout(main_hor_layout) + container_layout.addStretch() + + self.setStyleSheet(""" + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 3px; + } + """) + + # self.setStyleSheet(""" + # /* Hintergrund des Hauptfensters */ + # QWidget { + # background-color: #f1f5f9; + # } + + # /* Styling für unsere 3 Segmente */ + # QFrame#Segment { + # background-color: #ffffff; + # border: 1px solid #e2e8f0; + # border-radius: 12px; + # padding: 15px; + # } + + # /* Text-Styling */ + # QLabel { + # color: #334155; + # font-size: 16px; + # font-weight: bold; + # } + # """) + + def set_company_name(self, name: str = "") -> None: + if not name: + name = self.change_title_text.text() + if not name: + name = "n.v." + + txt = f"Unternehmen: {name}" + self.title_company_name.setText(txt) + + +class MasterDataTable(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Stammdaten Anzeige") + self.resize(450, 400) + + layout = QVBoxLayout(self) + layout.setContentsMargins(15, 15, 15, 15) + + # 1. Tabelle erstellen (0 Zeilen initial, 2 Spalten für Merkmal & Wert) + self.table = QTableWidget(0, 2) + + # --- DER TRICK FÜR DEN CLEANEN LOOK --- + self.table.horizontalHeader().hide() # Spalten-Header ausblenden + self.table.verticalHeader().hide() # Zeilen-Header (Nummerierung) ausblenden + self.table.setShowGrid(False) # Gitternetzlinien deaktivieren + + # Verhalten der Tabelle anpassen + self.table.setSelectionMode( + QAbstractItemView.SelectionMode.NoSelection + ) # Nicht auswählbar + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.NoEditTriggers + ) # Schreibgeschützt + + # Spaltenbreite steuern: + # Linke Spalte passt sich dem Text an, rechte Spalte füllt den Rest des Fensters + self.table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeMode.ResizeToContents + ) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + + layout.addWidget(self.table) + + # Daten laden + self.load_data() + self.style_table() + + def load_data(self): + + daten = [ + ("Anwendungsname", "MyQtApp Pro"), + ("Version", "2.4.1-beta"), + ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), + ("Letzter Login", "2026-06-25 09:30"), + ("Benutzer-ID", "USR-88291"), + ("Verschlüsselung", "AES-256-GCM"), + ] + + # Tabelle befüllen + self.table.setRowCount(len(daten)) + for zeile_index, (merkmal, wert) in enumerate(daten): + # Merkmal-Zelle (Links) + item_merkmal = QTableWidgetItem(merkmal) + item_merkmal.setTextAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(zeile_index, 0, item_merkmal) + + # Wert-Zelle (Rechts) + item_wert = QTableWidgetItem(wert) + item_wert.setTextAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(zeile_index, 1, item_wert) + + def style_table(self): + self.setStyleSheet(""" + QWidget { + background-color: transparent; + } + QTableWidget { + border: none; + background-color: transparent; + } + QTableWidget::item { + padding: 8px; + color: #334155; + font-size: 14px; + } + QTableWidget::item:first-column { + font-weight: bold; + color: #64748b; + padding-right: 20px; /* Mehr Abstand zum Wert */ + } + """) + + +class MasterDataFormWidget(QWidget): + def __init__(self): + super().__init__() + + # main layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + # form in frame + segment_frame = QFrame() + segment_frame.setObjectName("Segment") + # form layout + form_layout = QFormLayout(segment_frame) + form_layout.setSpacing(15) # Vertikaler Abstand zwischen den Zeilen + form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) # Merkmale linksbündig + + # load data + self.load_data(form_layout) + + main_layout.addWidget(segment_frame)n + self.style_ui() + + def load_data(self, form_layout): + daten = [ + ("Anwendungsname", "MyQtApp Pro"), + ("Version", "2.4.1-beta"), + ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), + ("Letzter Login", "2026-06-25 09:30"), + ("Benutzer-ID", "USR-88291"), + ("Verschlüsselung", "AES-256-GCM"), + ] + simulated_retrieved_data = {x[0]: x[1] for x in daten} + + for merkmal, wert in simulated_retrieved_data.items(): + lbl_merkmal = QLabel(f"{merkmal}") + lbl_merkmal.setObjectName("Merkmal") + + lbl_wert = QLabel(str(wert)) + lbl_wert.setObjectName("Wert") + + lbl_wert.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + form_layout.addRow(lbl_merkmal, lbl_wert) + + def style_ui(self): + self.setStyleSheet(""" + /* Hintergrund des Fensters */ + QWidget { + background-color: transparent; + } + + /* Unser optisch abgetrenntes Segment */ + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 5px; + } + + /* Textgröße für alle Elemente im Formular hochsetzen */ + QLabel { + font-size: 12px; + } + + /* Linke Spalte (Merkmale): Grau und Fett */ + QLabel#Merkmal { + font-weight: bold; + color: #64748b; + } + + /* Rechte Spalte (Werte): Dunkel und gut lesbar */ + QLabel#Wert { + color: #0f172a; + } + """) + + def clear_layout( layout: QLayout | None, ) -> None: @@ -2441,6 +2829,7 @@ class MainWindow(QMainWindow): self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) self.initrec_company.update_triggered.connect(self.update_grid) + self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' self.initrec_person = Page_InitRecPerson() @@ -2448,6 +2837,11 @@ class MainWindow(QMainWindow): self.initrec_person.back_requested.connect(self.show_new_entry_select) self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) + # SITE: 'Unternehmensprofil' + self.company_profile = Page_CompanyProfile() + self.company_profile.back_main_requested.connect(self.show_main_page) + self.company_profile.back_requested.connect(self.show_page_initrec_company) + self.stack.addWidget(self.company_profile) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle @@ -2613,6 +3007,9 @@ class MainWindow(QMainWindow): self.initrec_person.reset_form() self.stack.setCurrentWidget(self.initrec_person) + def show_page_company_profile(self) -> None: + self.stack.setCurrentWidget(self.company_profile) + # --- MENÜ LOGIK --- def create_menu(self): menu_bar = self.menuBar() -- 2.34.1 From 76ff68cb43c3d60e59c937c868ff13be5ccafb8a Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 25 Jun 2026 16:26:38 +0200 Subject: [PATCH 03/74] data loading of company profiles --- src/wce_crm/backend/backend.py | 14 +-- src/wce_crm/gui.py | 170 +++++++++++++++++++++++++-------- src/wce_crm/types.py | 3 + 3 files changed, 143 insertions(+), 44 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 3ccadf3..bbf3524 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -2,7 +2,7 @@ from __future__ import annotations import dataclasses as dc import datetime -from typing import Any, TypeAlias, TypedDict, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast import polars as pl import sqlalchemy as sql @@ -10,9 +10,11 @@ import sqlalchemy as sql from wce_crm import db from wce_crm.logging import logger_back as logger -InitRecId: TypeAlias = int +if TYPE_CHECKING: + from wce_crm.types import ExtAnId, ExtMaId, InitRecId +# // externals class CompanyInfo(TypedDict): ma_id: str wce_id: str @@ -100,7 +102,7 @@ def initrec_comp_search_choices() -> tuple[tuple[str, int], ...]: def initrec_comp_search_get_info( - ma_id: int, + ma_id: ExtMaId, ) -> CompanyInfo: logger.debug("[Call backend] comp_search_get_info") df = db.DF_CRM_MASTER.filter(pl.col.ma_id == ma_id) @@ -112,7 +114,7 @@ def initrec_comp_search_get_info( def initrec_comp_contact_person_search_choices( - ma_id: int | None, + ma_id: ExtMaId | None, use_both_names: bool, ) -> tuple[tuple[str, int], ...]: # TODO no reload functionality @@ -140,7 +142,7 @@ def initrec_comp_contact_person_search_choices( def initrec_comp_contact_person_search_get_info( - an_id: int, + an_id: ExtAnId, ) -> ContactPersonInfo: logger.debug("[Call backend] contact_person_search_get_info") df = db.DF_CONTACT_PERSON.filter(pl.col.an_id == an_id) @@ -151,6 +153,7 @@ def initrec_comp_contact_person_search_get_info( return cast(ContactPersonInfo, df.row(0, named=True)) +# // internals def initrec_insert_initial_recording( data: dict[str, Any], ) -> InitRecId: @@ -210,7 +213,6 @@ def initrec_delete_initial_recording( @dc.dataclass(slots=True) class FrontpageCompany: erfassung_id: InitRecId - # ma_id: int name: str Metadaten_aktualisierung: datetime.datetime is_company: bool diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 2453b64..f3a1be6 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -11,7 +11,7 @@ from collections import defaultdict from collections.abc import Container, Iterable, Sequence from pathlib import Path from pprint import pformat -from typing import Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast from typing_extensions import override from pydantic import ( @@ -70,6 +70,10 @@ from wce_crm.logging import ( logger_get_data, logger_gui, ) +from wce_crm.types import ExtMaId, InitRecId + +if TYPE_CHECKING: + ... K = TypeVar("K") V = TypeVar("V") @@ -1315,7 +1319,7 @@ class AutoForm(QWidget): self.delete_btn.clicked.connect(self.delete_data) self.layout_btn.addWidget(self.delete_btn) - self.current_id: int = -1 + self.current_id: InitRecId = -1 logger_auto_form.debug( "Initialised Auto Form Widget. Registry:%s", @@ -2072,7 +2076,7 @@ class NewEntrySelect_view(QWidget): """view for new entry for initial recording ("Grunderfassung")""" back_requested = Signal() # Signal back button - company_requested = Signal() # Signal "Unternehmen" + company_requested = Signal(bool) # Signal "Unternehmen" (bool: reset yes/no) person_requested = Signal() # Signal "Individualperson" def __init__(self): @@ -2094,7 +2098,7 @@ class NewEntrySelect_view(QWidget): btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) - btn_company.clicked.connect(lambda: self.company_requested.emit()) + btn_company.clicked.connect(lambda: self.company_requested.emit(True)) btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) @@ -2148,7 +2152,7 @@ class Page_InitRecCompany(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) - company_profile_requested = Signal() + company_profile_requested = Signal(InitRecId) def __init__(self): super().__init__() @@ -2172,7 +2176,9 @@ class Page_InitRecCompany(QWidget): header_layout.setContentsMargins(0, 0, 0, 10) comp_profile_btn = QPushButton("Unternehmensprofil") - comp_profile_btn.clicked.connect(lambda: self.company_profile_requested.emit()) + comp_profile_btn.clicked.connect( + lambda: self.company_profile_requested.emit(self.current_id) + ) comp_profile_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) comp_profile_btn.setMinimumWidth(250) # comp_profile_btn.setMaximumWidth(200) @@ -2281,6 +2287,10 @@ class Page_InitRecCompany(QWidget): def reset_form(self) -> None: self.auto_form.reset_form() + @property + def current_id(self) -> InitRecId: + return self.auto_form.current_id + class Page_InitRecPerson(QWidget): back_main_requested = Signal() # back to main page @@ -2413,12 +2423,27 @@ class Page_InitRecPerson(QWidget): class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page - back_requested = Signal() # back button + back_requested = Signal(bool) # back to init rec (bool: reset yes/no) # TODO update should trigger something else - update_triggered = Signal() # form saved (data changed for front page) + # update_triggered = Signal() # form saved (data changed for front page) def __init__(self): super().__init__() + + # state management + self.current_id: InitRecId = -1 + self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { + "ma_unternehmensname": "Name:", + "ma_branche": "Branche:", + "ma_strasse": "Straße:", + "ma_hausnummer": "Hausnummer:", + "ma_plz": "PLZ:", + "ma_ort": "Ort:", + "ma_website": "Webseite:", + "ma_mail": "E-Mail:", + "ma_telefonnummer": "Telefon:", + } + # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2444,7 +2469,7 @@ class Page_CompanyProfile(QWidget): back_btn_main.setMinimumWidth(200) back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") - back_btn_step.clicked.connect(lambda: self.back_requested.emit()) + back_btn_step.clicked.connect(lambda: self.back_requested.emit(False)) back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) @@ -2521,7 +2546,8 @@ class Page_CompanyProfile(QWidget): ) left_layout.addWidget(master_data_label) # self.table_master_data = MasterDataTable() - self.table_master_data = MasterDataFormWidget() + default_labels = tuple(self.COMPANY_FETAURES_MASTER_DATA.values()) + self.table_master_data = MasterDataFormWidget(default_labels) left_layout.addWidget(self.table_master_data) left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) @@ -2575,6 +2601,7 @@ class Page_CompanyProfile(QWidget): container_layout.addLayout(main_hor_layout) container_layout.addStretch() + # --- STYLE --- self.setStyleSheet(""" QFrame#Segment { background-color: #ffffff; @@ -2606,7 +2633,7 @@ class Page_CompanyProfile(QWidget): # } # """) - def set_company_name(self, name: str = "") -> None: + def set_company_name(self, name: str) -> None: if not name: name = self.change_title_text.text() if not name: @@ -2615,6 +2642,42 @@ class Page_CompanyProfile(QWidget): txt = f"Unternehmen: {name}" self.title_company_name.setText(txt) + def load_from_id( + self, + id_: InitRecId, + ) -> None: + assert id_ is not None, "tried to load with InitRecId None" + logger_gui.debug(f"Called function to load with ID: {id_}") + + if id_ < 0: + # just clear and do nothing else + logger_gui.debug("just reset with ID < 0") + self.table_master_data.reset() + self.set_company_name("") + self.current_id = id_ + return + + if id_ == self.current_id: + logger_gui.debug("ID did not change. Do nothing") + self.current_id = id_ + return + + # TODO retrieve and process data to load + init_rec = backend.initrec_get_initial_recording(id_) + ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) + assert ma_id is not None, "no ma_id obtained" + comp_info = backend.initrec_comp_search_get_info(ma_id) + + data: dict[str, Any] = {} + + for feature, value in comp_info.items(): + if feature in self.COMPANY_FETAURES_MASTER_DATA: + data[self.COMPANY_FETAURES_MASTER_DATA[feature]] = value + + self.table_master_data.load_data(data) + self.set_company_name(data["Name:"]) + self.current_id = id_ + class MasterDataTable(QWidget): def __init__(self): @@ -2705,8 +2768,13 @@ class MasterDataTable(QWidget): class MasterDataFormWidget(QWidget): - def __init__(self): + def __init__( + self, + default_labels: Sequence[str], + ): super().__init__() + self.default_labels = default_labels + self.default_data = {label: None for label in self.default_labels} # main layout main_layout = QVBoxLayout(self) @@ -2716,37 +2784,55 @@ class MasterDataFormWidget(QWidget): segment_frame = QFrame() segment_frame.setObjectName("Segment") # form layout - form_layout = QFormLayout(segment_frame) - form_layout.setSpacing(15) # Vertikaler Abstand zwischen den Zeilen - form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) # Merkmale linksbündig + self.form_layout = QFormLayout(segment_frame) + self.form_layout.setSpacing(15) + self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) # load data - self.load_data(form_layout) + # self.load_data() - main_layout.addWidget(segment_frame)n + main_layout.addWidget(segment_frame) self.style_ui() + self.reset() - def load_data(self, form_layout): - daten = [ - ("Anwendungsname", "MyQtApp Pro"), - ("Version", "2.4.1-beta"), - ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), - ("Letzter Login", "2026-06-25 09:30"), - ("Benutzer-ID", "USR-88291"), - ("Verschlüsselung", "AES-256-GCM"), - ] - simulated_retrieved_data = {x[0]: x[1] for x in daten} + def load_data( + self, + data: dict[str, Any], + ) -> None: + self.clear_current_layout() + # daten = [ + # ("Anwendungsname", "MyQtApp Pro"), + # ("Version", "2.4.1-beta"), + # ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), + # ("Letzter Login", "2026-06-25 09:30"), + # ("Benutzer-ID", "USR-88291"), + # ("Verschlüsselung", "AES-256-GCM"), + # ] + # simulated_retrieved_data = {x[0]: x[1] for x in daten} - for merkmal, wert in simulated_retrieved_data.items(): - lbl_merkmal = QLabel(f"{merkmal}") - lbl_merkmal.setObjectName("Merkmal") + for feature, value in data.items(): + label_feature = QLabel(f"{feature}") + label_feature.setObjectName("Merkmal") - lbl_wert = QLabel(str(wert)) - lbl_wert.setObjectName("Wert") + if value is None: + value = "" - lbl_wert.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + label_value = QLabel(str(value)) + label_value.setObjectName("Wert") + label_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - form_layout.addRow(lbl_merkmal, lbl_wert) + self.form_layout.addRow(label_feature, label_value) + + def clear_current_layout(self): + while self.form_layout.count() > 0: + item = self.form_layout.takeAt(0) + assert item is not None + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def reset(self) -> None: + self.load_data(self.default_data) def style_ui(self): self.setStyleSheet(""" @@ -2771,7 +2857,8 @@ class MasterDataFormWidget(QWidget): /* Linke Spalte (Merkmale): Grau und Fett */ QLabel#Merkmal { font-weight: bold; - color: #64748b; + /* color: #64748b; */ + color: #475569; } /* Rechte Spalte (Werte): Dunkel und gut lesbar */ @@ -2999,15 +3086,22 @@ class MainWindow(QMainWindow): def show_new_entry_select(self): self.stack.setCurrentWidget(self.new_entry_select) - def show_page_initrec_company(self): - self.initrec_company.reset_form() + def show_page_initrec_company(self, reset: bool): + # TODO: allow for "back" on profile to sustain current view + # TODO: reset if approached from "other side" by "new entry" + if reset: + self.initrec_company.reset_form() self.stack.setCurrentWidget(self.initrec_company) def show_page_initrec_person(self): self.initrec_person.reset_form() self.stack.setCurrentWidget(self.initrec_person) - def show_page_company_profile(self) -> None: + def show_page_company_profile( + self, + id_: InitRecId, + ) -> None: + self.company_profile.load_from_id(id_) self.stack.setCurrentWidget(self.company_profile) # --- MENÜ LOGIK --- diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index ff8d5fa..edec434 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -5,5 +5,8 @@ from typing import TYPE_CHECKING, TypeAlias if TYPE_CHECKING: import polars as pl +InitRecId: TypeAlias = int +ExtMaId: TypeAlias = int +ExtAnId: TypeAlias = int PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] -- 2.34.1 From b9427bc7569e8cbe73214785b5e16b3f328e43b5 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 26 Jun 2026 07:14:34 +0200 Subject: [PATCH 04/74] code cleansing --- src/wce_crm/gui.py | 55 +++------------------------------------------- 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index f3a1be6..156f27a 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -111,40 +111,6 @@ DATETIME_FMT: Final[str] = "%d.%m.%Y %H:%M:%S" DATE_FMT: Final[str] = "%d.%m.%Y" -# TODO check removal -def _save_pydantic_model_dict_db( - model: FlatBaseModel, - path: Path | None = None, -) -> None: - if path is None: - path = Path(__file__).parent / "model.pkl" - elif not path.is_file(): - raise ValueError("Path must point to a file") - - export = model.to_db() - - with open(path, "wb") as f: - pickle.dump(export, f) - - -# TODO check removal -def _load_pydantic_model_dict_db( - path: Path | None = None, -) -> dict[str, Any]: - if path is None: - path = Path(__file__).parent / "model.pkl" - elif not path.is_file(): - raise ValueError("Path must point to a file") - - if not path.exists(): - raise FileNotFoundError(f"Provided path for model dict not found: >{path}<") - - with open(path, "rb") as f: - model_dict = pickle.load(f) - - return model_dict - - def merge_dicts_to_lists( dict_iter: Iterable[dict[K, V]], ) -> dict[K, list[V]]: @@ -169,7 +135,7 @@ def unmerge_dict_to_list( return [dict(zip(keys, row)) for row in zip(*value_lists)] -# TODO check removal +# TODO check removal or move to basics package def _get_leafs(data): if isinstance(data, dict): for value in data.values(): @@ -181,7 +147,7 @@ def _get_leafs(data): yield data -# TODO check removal +# TODO check removal or move to basics package def _get_leaf_dicts(data): if isinstance(data, dict): has_inner_dicts = False @@ -1467,7 +1433,6 @@ class AutoForm(QWidget): logger_auto_form.info("Data saved successfully") self.update_triggered.emit() self._activation_delete() - # self.reset_form() # TODO check if this behaviour is expected finally: # always re-enable save, even if error occurred self._enable_save() @@ -2425,6 +2390,7 @@ class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal(bool) # back to init rec (bool: reset yes/no) # TODO update should trigger something else + # more like listening to 'child' ("Beratungen") # update_triggered = Signal() # form saved (data changed for front page) def __init__(self): @@ -2662,7 +2628,6 @@ class Page_CompanyProfile(QWidget): self.current_id = id_ return - # TODO retrieve and process data to load init_rec = backend.initrec_get_initial_recording(id_) ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) assert ma_id is not None, "no ma_id obtained" @@ -2788,9 +2753,6 @@ class MasterDataFormWidget(QWidget): self.form_layout.setSpacing(15) self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) - # load data - # self.load_data() - main_layout.addWidget(segment_frame) self.style_ui() self.reset() @@ -2800,15 +2762,6 @@ class MasterDataFormWidget(QWidget): data: dict[str, Any], ) -> None: self.clear_current_layout() - # daten = [ - # ("Anwendungsname", "MyQtApp Pro"), - # ("Version", "2.4.1-beta"), - # ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), - # ("Letzter Login", "2026-06-25 09:30"), - # ("Benutzer-ID", "USR-88291"), - # ("Verschlüsselung", "AES-256-GCM"), - # ] - # simulated_retrieved_data = {x[0]: x[1] for x in daten} for feature, value in data.items(): label_feature = QLabel(f"{feature}") @@ -3087,8 +3040,6 @@ class MainWindow(QMainWindow): self.stack.setCurrentWidget(self.new_entry_select) def show_page_initrec_company(self, reset: bool): - # TODO: allow for "back" on profile to sustain current view - # TODO: reset if approached from "other side" by "new entry" if reset: self.initrec_company.reset_form() self.stack.setCurrentWidget(self.initrec_company) -- 2.34.1 From df9391494249f7d8d8dd22a1777e22701af93cb1 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 26 Jun 2026 14:14:45 +0200 Subject: [PATCH 05/74] switch to new data model with distinct company and person data --- ...4_baseline_data_model_with_new_approach.py | 118 +++++++++++++++ ...added_new_column_and_renamed_table_for_.py | 37 ----- data_model/project_db_schema.aml | 98 ++++++++++++ src/wce_crm/backend/backend.py | 99 ++++++++++-- src/wce_crm/custom_widget_registry.py | 2 +- src/wce_crm/data_models.py | 16 +- src/wce_crm/db.py | 142 ++++++++---------- src/wce_crm/form_defs.py | 71 +-------- src/wce_crm/gui.py | 118 ++++++++------- 9 files changed, 436 insertions(+), 265 deletions(-) create mode 100644 alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py delete mode 100644 alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py create mode 100644 data_model/project_db_schema.aml diff --git a/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py b/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py new file mode 100644 index 0000000..83e0c40 --- /dev/null +++ b/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py @@ -0,0 +1,118 @@ +"""Baseline: data model with new approach + +Revision ID: 1a8e957522a4 +Revises: +Create Date: 2026-06-26 13:47:48.937123 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "1a8e957522a4" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "grunderfassung_personen", + sa.Column("pers_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("Metadaten_erstellung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_aktualisierung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.Date(), nullable=True), + sa.Column("Arbeitserfahrung", sa.Text(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("HoehereBildung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.Text(), nullable=True), + sa.Column("Schulbildung", sa.Text(), nullable=True), + sa.Column("Sprachkenntnisse", sa.Text(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.Text(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.Text(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.Text(), nullable=True), + sa.Column("Stammdaten__land", sa.Text(), nullable=True), + sa.Column("Stammdaten__email", sa.Text(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.Text(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.Date(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.Text(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__name", sa.Text(), nullable=True), + sa.Column("Stammdaten__ort", sa.Text(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.Boolean(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.Text(), nullable=True), + sa.Column("Stammdaten__strasse", sa.Text(), nullable=True), + sa.Column("Stammdaten__titel", sa.Text(), nullable=True), + sa.Column("Stammdaten__vorname", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + op.create_table( + "grunderfassung_unternehmen", + sa.Column("un_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("Metadaten_erstellung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_aktualisierung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.Text(), nullable=True), + sa.Column("Partnersuche__person_suche", sa.Integer(), nullable=True), + sa.Column("Partnersuche__un_suche", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "zuordnung_personen_unternehmen", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=False), + sa.Column("pers_id", sa.Integer(), nullable=False), + sa.Column("gueltig_ab", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("gueltig_bis", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("zuordnung_personen_unternehmen") + op.drop_table("grunderfassung_unternehmen") + op.drop_table("grunderfassung_personen") + # ### end Alembic commands ### diff --git a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py deleted file mode 100644 index dc4e595..0000000 --- a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py +++ /dev/null @@ -1,37 +0,0 @@ -"""added new column and renamed table for initial recording - -Revision ID: 5f2af5179c47 -Revises: -Create Date: 2026-06-17 16:06:46.421562 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "5f2af5179c47" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.rename_table("grunderfassung_unternehmen", "grunderfassung") - op.add_column( - "grunderfassung", - sa.Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("grunderfassung", "Metadaten_wiedereintrittsdatum") - op.rename_table("grunderfassung", "grunderfassung_unternehmen") - # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml new file mode 100644 index 0000000..16e697b --- /dev/null +++ b/data_model/project_db_schema.aml @@ -0,0 +1,98 @@ +Ext_CRM.Master + ma_id int pk + + +Ext_CRM.Ansprechpartner + an_id int pk + ma_id int -> Ext_CRM.Master(ma_id) + + +Grunderfassung_Unternehmen + un_id int pk {autoIncrement} + Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable + Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + Metadaten_nutzer varchar(20) nullable + Grunderfassung_fallnummer text nullable + Grunderfassung_notiz text nullable + Partnersuche__un_suche int nullable -> Ext_CRM.Master(ma_id) + Partnersuche__person_suche int nullable -> Ext_CRM.Ansprechpartner(an_id) + Partnersuche__kanal_aufmerksamkeit text nullable + + +Grunderfassung_Personen + pers_id int pk {autoIncrement} + Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable + Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + Metadaten_wiedereintrittsdatum timestamp nullable + Metadaten_nutzer varchar(20) nullable + Grunderfassung_fallnummer text nullable + Grunderfassung_notiz text nullable + HoehereBildung text nullable + Kontaktperson__KP_adresse text nullable + Kontaktperson__KP_anrede_anschrift text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_festnetznummer text nullable + Kontaktperson__KP_funktion_beziehung text nullable + Kontaktperson__KP_mobilfunknummer text nullable + Kontaktperson__KP_name_partner text nullable + Kontaktperson__KP_titel text nullable + Kontaktperson__KP_vorname text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_email text nullable + Projektrelevanz__relevanz text nullable + Projektrelevanz__foerderperiode text nullable + Schulbildung text nullable + Stammdaten__PLZ text nullable + Stammdaten__anrede_anschrift text nullable + Stammdaten__anzahl_kinder__alter text nullable + Stammdaten__anzahl_kinder__anzahl int nullable + Stammdaten__aufenthaltsort text nullable + Stammdaten__bundesland text nullable + Stammdaten__land text nullable + Stammdaten__email text nullable + Stammdaten__familienstand text nullable + Stammdaten__festnetznummer text nullable + Stammdaten__geburtsdatum date nullable + Stammdaten__hausnummer text nullable + Stammdaten__herkunftsland text nullable + Stammdaten__mobilfunknummer text nullable + Stammdaten__name text nullable + Stammdaten__ort text nullable + Stammdaten__rueckkehrer bool nullable + Stammdaten__staatsangehoerigkeit text nullable + Stammdaten__strasse text nullable + Stammdaten__titel text nullable + Stammdaten__vorname text nullable + WeitereInfos__WI_arbeitsstatus text nullable + WeitereInfos__WI_aufenthaltstitel text nullable + WeitereInfos__WI_deutsch_sprache text nullable + WeitereInfos__WI_gueltigkeit_aufenthaltstitel text nullable + WeitereInfos__WI_meldung_institution text nullable + + +Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehören, ein Unternehmen kann mehrere Personen betreuen. + id int pk {autoIncrement} + un_id int -> Grunderfassung_Unternehmen(un_id) + pers_id int -> Grunderfassung_Personen(pers_id) + gueltig_ab date + gueltig_bis date nullable + + +Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein (pauschal) oder beide (individual) gebunden sein + vorgang_id int pk {autoIncrement} + un_id int nullable -> Grunderfassung_Unternehmen(un_id) | Pauschalberatungen sind sowohl für Unternehmen als auch Personen zulässig + pers_id int nullable -> Grunderfassung_Personen(pers_id) | sowohl Pauschal- als auch Individualberatung bei Personenbindung möglich + titel text + beratung_typ berat_typ(pauschal, individual) + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind + + +Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden + beratung_id int pk {autoIncrement} + vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + weitere_merkmale_ausstehend text nullable # Merkmale noch zu ergänzen diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index bbf3524..d729aac 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -154,11 +154,11 @@ def initrec_comp_contact_person_search_get_info( # // internals -def initrec_insert_initial_recording( +def initrec_company_insert_initial_recording( data: dict[str, Any], ) -> InitRecId: logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung.insert() + stmt = db.grunderfassung_unternehmen.insert() with db.ENGINE.begin() as conn: ret = conn.execute(stmt, data) @@ -171,21 +171,25 @@ def initrec_insert_initial_recording( return prim_keys[0] -def initrec_update_initial_recording( +def initrec_company_update_initial_recording( id_: InitRecId, data: dict[str, Any], ) -> None: logger.debug("[Call backend] update_initial_recording") - stmt = db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_) + stmt = db.grunderfassung_unternehmen.update().where( + db.grunderfassung_unternehmen.c.un_id == id_ + ) with db.ENGINE.begin() as conn: conn.execute(stmt, data) -def initrec_get_initial_recording( +def initrec_company_get_initial_recording( id_: InitRecId, ) -> dict[str, Any]: logger.debug("[Call backend] get_initial_recording") - stmt = db.grunderfassung.select().where(db.grunderfassung.c.erfassung_id == id_) + stmt = db.grunderfassung_unternehmen.select().where( + db.grunderfassung_unternehmen.c.un_id == id_ + ) with db.ENGINE.begin() as conn: ret = conn.execute(stmt) @@ -198,11 +202,75 @@ def initrec_get_initial_recording( return row._asdict() -def initrec_delete_initial_recording( +def initrec_company_delete_initial_recording( id_: InitRecId, ) -> None: logger.debug("[Call backend] delete_initial_recording") - stmt = db.grunderfassung.delete().where(db.grunderfassung.c.erfassung_id == id_) + stmt = db.grunderfassung_unternehmen.delete().where( + db.grunderfassung_unternehmen.c.un_id == id_ + ) + with db.ENGINE.begin() as conn: + ret = conn.execute(stmt) + + if ret.rowcount == 0: + raise KeyError(f"Database ID {id_} not found for deletion") + + +def initrec_person_insert_initial_recording( + data: dict[str, Any], +) -> InitRecId: + logger.debug("[Call backend] insert_initial_recording") + stmt = db.grunderfassung_personen.insert() + with db.ENGINE.begin() as conn: + ret = conn.execute(stmt, data) + + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + + prim_keys = ret.inserted_primary_key + assert prim_keys + + return prim_keys[0] + + +def initrec_person_update_initial_recording( + id_: InitRecId, + data: dict[str, Any], +) -> None: + logger.debug("[Call backend] update_initial_recording") + stmt = db.grunderfassung_personen.update().where( + db.grunderfassung_personen.c.pers_id == id_ + ) + with db.ENGINE.begin() as conn: + conn.execute(stmt, data) + + +def initrec_person_get_initial_recording( + id_: InitRecId, +) -> dict[str, Any]: + logger.debug("[Call backend] get_initial_recording") + stmt = db.grunderfassung_personen.select().where( + db.grunderfassung_personen.c.pers_id == id_ + ) + with db.ENGINE.begin() as conn: + ret = conn.execute(stmt) + + if ret.rowcount == 0: + raise KeyError(f"Database ID {id_} not found") + + row = ret.fetchone() + assert row, "row was not obtained" + + return row._asdict() + + +def initrec_person_delete_initial_recording( + id_: InitRecId, +) -> None: + logger.debug("[Call backend] delete_initial_recording") + stmt = db.grunderfassung_personen.delete().where( + db.grunderfassung_personen.c.pers_id == id_ + ) with db.ENGINE.begin() as conn: ret = conn.execute(stmt) @@ -218,15 +286,16 @@ class FrontpageCompany: is_company: bool +# TODO separate functions for companies and person: focus on companies first def front_get_company_list() -> list[FrontpageCompany]: logger.debug("[Call backend] get_company_list") stmt = sql.select( - db.grunderfassung.c.erfassung_id, - db.grunderfassung.c.Partnersuche__un_suche, - db.grunderfassung.c.Stammdaten__vorname, - db.grunderfassung.c.Stammdaten__name, - db.grunderfassung.c.Metadaten_aktualisierung, - ).order_by(db.grunderfassung.c.Metadaten_aktualisierung.desc()) + db.grunderfassung_unternehmen.c.un_id, + db.grunderfassung_unternehmen.c.Partnersuche__un_suche, + # db.grunderfassung_unternehmen.c.Stammdaten__vorname, + # db.grunderfassung_unternehmen.c.Stammdaten__name, + db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, + ).order_by(db.grunderfassung_unternehmen.c.Metadaten_aktualisierung.desc()) with db.ENGINE.connect() as conn: res = conn.execute(stmt) @@ -234,7 +303,7 @@ def front_get_company_list() -> list[FrontpageCompany]: front_page_companies: list[FrontpageCompany] = [] for entry in res.mappings(): - erfassung_id = entry["erfassung_id"] + erfassung_id = entry["un_id"] ma_id = entry["Partnersuche__un_suche"] datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) datetime_akt = datetime_akt.astimezone() diff --git a/src/wce_crm/custom_widget_registry.py b/src/wce_crm/custom_widget_registry.py index f1c6d8a..1d4cb09 100644 --- a/src/wce_crm/custom_widget_registry.py +++ b/src/wce_crm/custom_widget_registry.py @@ -4,6 +4,6 @@ from typing import Final CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset( [ - "grunderfassung_suche", + "grunderfassung_unternehmen_suche", ] ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 9575ea7..88efdbd 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -83,7 +83,7 @@ class FlatBaseModel(BaseModel): @model_validator(mode="before") @classmethod - def __unflatten_input( + def _unflatten_input( cls, data: Any, ) -> Any: # type: ignore @@ -141,7 +141,18 @@ class FlatBaseModel(BaseModel): return dict(items) -class Grunderfassung(FlatBaseModel): +class Grunderfassung_Unternehmen(FlatBaseModel): + # default in SQLAlchemy with lambda and timezone-aware datetime + Metadaten_erstellung: AwareDatetime | None = None + Metadaten_aktualisierung: AwareDatetime | None = None # see above + Metadaten_nutzer: str | None + Grunderfassung_fallnummer: str + Grunderfassung_notiz: str | None + + Partnersuche: Grunderfassung_PartnerSuche + + +class Grunderfassung_Personen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime Metadaten_erstellung: AwareDatetime | None = None Metadaten_aktualisierung: AwareDatetime | None = None # see above @@ -150,7 +161,6 @@ class Grunderfassung(FlatBaseModel): Grunderfassung_fallnummer: str Grunderfassung_notiz: str | None - Partnersuche: Grunderfassung_PartnerSuche | None = None Projektrelevanz: Grunderfassung_Projektrelevanz Kontaktperson: Grunderfassung_Kontaktperson Stammdaten: Grunderfassung_Stammdaten diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 2c2a2a2..749fddb 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -62,83 +62,6 @@ MD_CRM = sql.MetaData() MD_MAIN = sql.MetaData() ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") -# ---------- OLD "Kontaktliste" ---------- - -# md_kontaktliste = sql.MetaData() - -# ext_kl_unternehmen: sql.Table = Table( -# "Unternehmen", -# md_kontaktliste, -# Column("u_id", sql.Integer, nullable=False, unique=True), -# Column("u_zeitstempel_eintrag", sql.DateTime, nullable=False), -# Column("u_rechtsform", sql.Text, nullable=False), -# Column("u_firmenname", sql.Text, nullable=False), -# Column("u_strasse", sql.Text, nullable=False), -# Column("u_hausnummer", sql.Text, nullable=False), -# Column("u_adresszusatz", sql.Text, nullable=True), -# Column("u_plz", sql.Text, nullable=False), -# Column("u_ort", sql.Text, nullable=False), -# Column("u_postfach", sql.Text, nullable=True), -# Column("u_website", sql.Text, nullable=True), -# Column("u_anrede", sql.Text, nullable=False), -# Column("u_titel", sql.Text, nullable=True), -# Column("u_vorname", sql.Text, nullable=False), -# Column("u_nachname", sql.Text, nullable=False), -# Column("u_funktion", sql.Text, nullable=False), -# Column("u_mail", sql.Text, nullable=False), -# Column("u_telefon", sql.Text, nullable=False), -# Column("u_plz_postfach", sql.Text, nullable=True), -# Column("u_einwilligung_inhaber", sql.Boolean, nullable=True), -# Column("u_einwilligung_ansprechpartner", sql.Boolean, nullable=True), -# Column("u_aktiv", sql.Boolean, nullable=False, default=1), -# ) - -# ext_kl_unternehmen_schema: t.PolarsSchema = { -# "u_id": pl.UInt64, -# "u_zeitstempel_eintrag": pl.Datetime, -# "u_rechtsform": pl.String, -# "u_firmenname": pl.String, -# "u_strasse": pl.String, -# "u_hausnummer": pl.String, -# "u_adresszusatz": pl.String, -# "u_plz": pl.String, -# "u_ort": pl.String, -# "u_postfach": pl.String, -# "u_website": pl.String, -# "u_anrede": pl.String, -# "u_titel": pl.String, -# "u_vorname": pl.String, -# "u_nachname": pl.String, -# "u_funktion": pl.String, -# "u_mail": pl.String, -# "u_telefon": pl.String, -# "u_plz_postfach": pl.String, -# "u_einwilligung_inhaber": pl.Boolean, -# "u_einwilligung_ansprechpartner": pl.Boolean, -# "u_aktiv": pl.Boolean, -# } - - -# def get_ext_kontaktliste( -# db_path: Path | None, -# ) -> pl.DataFrame: -# if db_path is None: -# ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None) -# if ENV_PTH is None: -# raise ValueError("No database path provided or found as ENV var.") -# db_path = Path(ENV_PTH) - -# if not db_path.exists(): -# raise FileNotFoundError(f"Database not found under >{db_path}<") - -# engine = sql.create_engine(f"sqlite:///{db_path}") -# stmt = sql.select(ext_kl_unternehmen) -# return pl.read_database(stmt, engine, schema_overrides=ext_kl_unternehmen_schema) - - -# df_kontaktliste = get_ext_kontaktliste(None) - -# ---------------------------------------------------- ext_crm_master: sql.Table = Table( "Master", @@ -324,11 +247,41 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) -grunderfassung: sql.Table = Table( - "grunderfassung", +grunderfassung_unternehmen: sql.Table = Table( + "grunderfassung_unternehmen", MD_MAIN, Column( - "erfassung_id", + "un_id", + sql.Integer, + primary_key=True, + autoincrement=True, + ), + Column( + "Metadaten_erstellung", + UTCDateTime, + nullable=True, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "Metadaten_aktualisierung", + UTCDateTime, + nullable=True, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), + Column("Metadaten_nutzer", sql.String(20), nullable=True), + Column("Grunderfassung_fallnummer", sql.Text, nullable=True), + Column("Grunderfassung_notiz", sql.Text, nullable=True), + Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), + Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), + Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), +) + +grunderfassung_personen: sql.Table = Table( + "grunderfassung_personen", + MD_MAIN, + Column( + "pers_id", sql.Integer, primary_key=True, autoincrement=True, @@ -364,9 +317,6 @@ grunderfassung: sql.Table = Table( ), # TODO: check if needed when set by trigger Column("Kontaktperson__KP_titel", sql.Text, nullable=True), Column("Kontaktperson__KP_vorname", sql.Text, nullable=True), - Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), - Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), - Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), Column("Projektrelevanz__relevanz", sql.Text, nullable=True), Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True), Column("Schulbildung", sql.Text, nullable=True), @@ -398,3 +348,29 @@ grunderfassung: sql.Table = Table( Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True), Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), ) + +zuordnung_personen_unternehmen: sql.Table = Table( + "zuordnung_personen_unternehmen", + MD_MAIN, + Column( + "id", + sql.Integer, + primary_key=True, + autoincrement=True, + ), + Column("un_id", sql.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=False), + Column("pers_id", sql.ForeignKey("grunderfassung_personen.pers_id"), nullable=False), + Column( + "gueltig_ab", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "gueltig_bis", + UTCDateTime, + nullable=True, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), +) diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 3986bbc..c000d8c 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -920,79 +920,10 @@ INITREC_COMP = [ FormField( "Suche", FormFieldType.CUSTOM, - custom_widget="grunderfassung_suche", + custom_widget="grunderfassung_unternehmen_suche", key="Partnersuche", children=INITREC_COMP_SEARCH_HEAD, ), - FormField( - "Status && Projektrelevanz", - FormFieldType.GROUP, - key="Projektrelevanz", - children=[ - FormField( - "Projektrelevanz", - FormFieldType.DYNAMIC_DROPDOWN_OPTION, - key="", - trigger_value="ja", - children=[ - FormField( - "Relevanz", - FormFieldType.DROPDOWN, - required=True, - options=[("ja", None), ("nein", None)], - key="relevanz", - children=[ - FormField( - "Förderperiode", FormFieldType.TEXT, key="foerderperiode" - ), - ], - ), - ], - ), - ], - ), - FormField( - "Daten Kontaktperson", - FormFieldType.GROUP, - key="Kontaktperson", - children=INITREC_CONTACT_PERSON, - ), - FormField( - "Stammdaten", - FormFieldType.GROUP, - key="Stammdaten", - children=INITREC_MASTER_DATA, - ), - FormField( - "Weitere Informationen", - FormFieldType.GROUP, - key="WeitereInfos", - children=INITREC_ADDITIONAL_DATA, - ), - FormField( - "Schulbildung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_SCHOOL, - key="Schulbildung", - ), - FormField( - "Studium/Ausbildung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_HIGHER_EDUCATION, - key="HoehereBildung", - ), - FormField( - "Arbeitserfahrung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_WORK_EXPERIENCE, - key="Arbeitserfahrung", - ), - FormField( - "Sprachkenntnisse", - FormFieldType.DYNAMIC_LIST, - children=INITREC_LANGUAGES, - key="Sprachkenntnisse", - ), ] INITREC_PERSON = [ diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 156f27a..d3eeb85 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -58,7 +58,12 @@ from PySide6.QtWidgets import ( import wce_crm.constants from wce_crm.backend import backend -from wce_crm.data_models import COLUMN_SEP, FlatBaseModel, Grunderfassung +from wce_crm.data_models import ( + COLUMN_SEP, + FlatBaseModel, + Grunderfassung_Personen, + Grunderfassung_Unternehmen, +) from wce_crm.form_defs import ( INITREC_COMP, INITREC_PERSON, @@ -1351,7 +1356,7 @@ class AutoForm(QWidget): logger_auto_form.debug( "Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) ) - model = Grunderfassung(**loaded_data) + model = self.cfg.model(**loaded_data) logger_auto_form.debug("Loaded to Pydantic.") logger_auto_form.debug("Convert to GUI structure...") form_data = model.to_gui() @@ -2081,35 +2086,33 @@ class NewEntrySelect_view(QWidget): CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( - model=Grunderfassung, - data_insert=backend.initrec_insert_initial_recording, - data_update=backend.initrec_update_initial_recording, - data_get=backend.initrec_get_initial_recording, - data_delete=backend.initrec_delete_initial_recording, + model=Grunderfassung_Unternehmen, + data_insert=backend.initrec_company_insert_initial_recording, + data_update=backend.initrec_company_update_initial_recording, + data_get=backend.initrec_company_get_initial_recording, + data_delete=backend.initrec_company_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", - "Metadaten_wiedereintrittsdatum", ), form_fields=INITREC_COMP, ) CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( - model=Grunderfassung, - data_insert=backend.initrec_insert_initial_recording, - data_update=backend.initrec_update_initial_recording, - data_get=backend.initrec_get_initial_recording, - data_delete=backend.initrec_delete_initial_recording, + model=Grunderfassung_Personen, + data_insert=backend.initrec_person_insert_initial_recording, + data_update=backend.initrec_person_update_initial_recording, + data_get=backend.initrec_person_get_initial_recording, + data_delete=backend.initrec_person_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", - "Partnersuche", ), form_fields=INITREC_PERSON, ) CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { - "grunderfassung_suche": Grunderfassung_SuchWidget, + "grunderfassung_unternehmen_suche": Grunderfassung_SuchWidget, } @@ -2203,51 +2206,53 @@ class Page_InitRecCompany(QWidget): self.search_widget_trigger = cast( QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) - text_widget_set = search_widgets_by_key( - self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" - ) - assert len(text_widget_set) == 1 - self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) - self.search_widget_trigger.textChanged.connect( - self._custom_set_company_name_contact_person - ) + # TODO check how this should be done with new architecture + # text_widget_set = search_widgets_by_key( + # self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" + # ) + # assert len(text_widget_set) == 1 + # self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) + # self.search_widget_trigger.textChanged.connect( + # self._custom_set_company_name_contact_person + # ) # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' - person_location = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" - ) - assert len(person_location) == 1 - self.person_location = cast(QComboBox, person_location[0]["widget"]) - assert isinstance(self.person_location, QComboBox) - self.person_location.currentIndexChanged.connect(self._custom_county_selection) + # TODO check removal + # person_location = search_widgets_by_key( + # self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" + # ) + # assert len(person_location) == 1 + # self.person_location = cast(QComboBox, person_location[0]["widget"]) + # assert isinstance(self.person_location, QComboBox) + # self.person_location.currentIndexChanged.connect(self._custom_county_selection) - selection_county = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" - ) - assert len(selection_county) == 1 - self.selection_county = cast(QComboBox, selection_county[0]["widget"]) - assert isinstance(self.selection_county, QComboBox) - self.selection_county.setProperty("styleClass", "stempel") - self.selection_county.setEnabled(False) + # selection_county = search_widgets_by_key( + # self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + # ) + # assert len(selection_county) == 1 + # self.selection_county = cast(QComboBox, selection_county[0]["widget"]) + # assert isinstance(self.selection_county, QComboBox) + # self.selection_county.setProperty("styleClass", "stempel") + # self.selection_county.setEnabled(False) - def _custom_set_company_name_contact_person(self, value: str) -> None: - self.text_widget_set.setText(str(value)) + # def _custom_set_company_name_contact_person(self, value: str) -> None: + # self.text_widget_set.setText(str(value)) - def _custom_county_selection(self, idx: int) -> None: - value = self.person_location.itemData(idx) - if value == "Inland": - self.selection_county.setEnabled(True) - self.selection_county.setProperty("styleClass", "") - self.selection_county.style().unpolish(self.selection_county) - self.selection_county.style().polish(self.selection_county) - self.selection_county.update() - else: - self.selection_county.setCurrentIndex(0) - self.selection_county.setEnabled(False) - self.selection_county.setProperty("styleClass", "stempel") - self.selection_county.style().unpolish(self.selection_county) - self.selection_county.style().polish(self.selection_county) - self.selection_county.update() + # def _custom_county_selection(self, idx: int) -> None: + # value = self.person_location.itemData(idx) + # if value == "Inland": + # self.selection_county.setEnabled(True) + # self.selection_county.setProperty("styleClass", "") + # self.selection_county.style().unpolish(self.selection_county) + # self.selection_county.style().polish(self.selection_county) + # self.selection_county.update() + # else: + # self.selection_county.setCurrentIndex(0) + # self.selection_county.setEnabled(False) + # self.selection_county.setProperty("styleClass", "stempel") + # self.selection_county.style().unpolish(self.selection_county) + # self.selection_county.style().polish(self.selection_county) + # self.selection_county.update() def reset_form(self) -> None: self.auto_form.reset_form() @@ -2330,6 +2335,7 @@ class Page_InitRecPerson(QWidget): # --- CUSTOM LOGIC --- # ** fill 'Kontaktperson -> Namen Unternehmen' + # TODO check how partners are selected with new architecture # search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") # assert len(search_res) == 1 # search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) @@ -2628,7 +2634,7 @@ class Page_CompanyProfile(QWidget): self.current_id = id_ return - init_rec = backend.initrec_get_initial_recording(id_) + init_rec = backend.initrec_company_get_initial_recording(id_) ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) assert ma_id is not None, "no ma_id obtained" comp_info = backend.initrec_comp_search_get_info(ma_id) -- 2.34.1 From 04bfac8f53509bf3c05a3d95cee073ae8457eab9 Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 30 Jun 2026 10:56:11 +0200 Subject: [PATCH 06/74] prepare consulting page --- src/wce_crm/gui.py | 346 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 6 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index d3eeb85..86b2e9e 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy import dataclasses as dc import datetime +import enum import pickle import re import sys @@ -2392,12 +2393,25 @@ class Page_InitRecPerson(QWidget): self.auto_form.reset_form() +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIUAL = enum.auto() + + +@dc.dataclass(slots=True, kw_only=True) +class ConsultingRequest: + cons_type: ConsultingType + un_id: InitRecId | None = None + pers_id: InitRecId | None = None + + class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal(bool) # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") # update_triggered = Signal() # form saved (data changed for front page) + consulting_requested = Signal(ConsultingRequest) def __init__(self): super().__init__() @@ -2541,6 +2555,9 @@ class Page_CompanyProfile(QWidget): ) top_right_layout.addWidget(common_consultancy_label) top_right_new_btn = QPushButton("+ Hinzufügen") + top_right_new_btn.clicked.connect( + lambda: self._request_consulting(ConsultingType.PAUSCHAL) + ) top_right_new_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) @@ -2560,6 +2577,9 @@ class Page_CompanyProfile(QWidget): ) bottom_right_layout.addWidget(individual_consultancy_label) bottom_right_new_btn = QPushButton("+ Hinzufügen") + bottom_right_new_btn.clicked.connect( + lambda: self._request_consulting(ConsultingType.INDIVIUAL) + ) bottom_right_new_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) @@ -2605,6 +2625,16 @@ class Page_CompanyProfile(QWidget): # } # """) + def _request_consulting( + self, + cons_type: ConsultingType, + ) -> None: + assert self.current_id > -1, ( + "tried to call consulting from comp profile without set ID" + ) + req = ConsultingRequest(cons_type=cons_type, un_id=self.current_id) + self.consulting_requested.emit(req) + def set_company_name(self, name: str) -> None: if not name: name = self.change_title_text.text() @@ -2619,6 +2649,7 @@ class Page_CompanyProfile(QWidget): id_: InitRecId, ) -> None: assert id_ is not None, "tried to load with InitRecId None" + assert id_, "tried to load with invalid InitRecId" logger_gui.debug(f"Called function to load with ID: {id_}") if id_ < 0: @@ -2827,6 +2858,271 @@ class MasterDataFormWidget(QWidget): """) +class Page_Consulting(QWidget): + back_main_requested = Signal() # back to main page + back_requested = Signal(bool) # back to init rec (bool: reset yes/no) + # TODO update should trigger something else + # more like listening to 'child' ("Beratungen") + # update_triggered = Signal() # form saved (data changed for front page) + + def __init__(self): + super().__init__() + + # state management + # self.current_id: InitRecId = -1 + # self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { + # "ma_unternehmensname": "Name:", + # "ma_branche": "Branche:", + # "ma_strasse": "Straße:", + # "ma_hausnummer": "Hausnummer:", + # "ma_plz": "PLZ:", + # "ma_ort": "Ort:", + # "ma_website": "Webseite:", + # "ma_mail": "E-Mail:", + # "ma_telefonnummer": "Telefon:", + # } + + # Hauptlayout der Seite + outer_layout = QHBoxLayout(self) + vert_layout = QVBoxLayout() + # main_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.addStretch(1) + outer_layout.addLayout(vert_layout, stretch=100) + outer_layout.addStretch(1) + # Optional: Damit der Container oben am Rand klebt + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # --- HEADER --- + header_container = QWidget() + header_container.setMinimumWidth(700) + header_container.setMaximumWidth(1200) + header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + header_layout = QVBoxLayout(header_container) + header_layout.setContentsMargins(0, 0, 0, 10) + + back_btn_main = QPushButton("← Zurück zur Übersicht") + back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) + back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_main.setMinimumWidth(200) + back_btn_main.setMaximumWidth(200) + back_btn_step = QPushButton("← Zurück") + back_btn_step.clicked.connect(lambda: self.back_requested.emit(False)) + back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_step.setMinimumWidth(200) + back_btn_step.setMaximumWidth(200) + + title = QLabel("Beratungsgespräch") + title.setStyleSheet("font-size: 20px; font-weight: bold;") + + header_layout.setSpacing(5) + + header_layout.addSpacing(15) + header_layout.addWidget(back_btn_step) + header_layout.addWidget(back_btn_main) + header_layout.addWidget(title) + vert_layout.addWidget(header_container) + + # --- MAIN CONTENT --- + container = QWidget() + # SCROLL AREA + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1200) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # optional: remove frame to look more modern + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + vert_layout.addWidget(scroll_area) + + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + + # --- MAIN LAYOUT --- + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator1) + + # layout_change_title = QHBoxLayout() + # self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...") + # layout_change_title.addWidget(self.change_title_text) + # change_title_btn = QPushButton("ändern") + # layout_change_title.addWidget(change_title_btn) + # change_title_btn.clicked.connect(self.set_company_name) + # container_layout.addLayout(layout_change_title) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator2) + + container_layout.addSpacing(30) + + # self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) + # container_layout.addWidget(self.title_company_name) + container_layout.addWidget(QLabel("PLATZHALTER")) + + container_layout.addSpacing(20) + + # separation in left and right + # main_hor_layout = QHBoxLayout() + # main_hor_layout.setSpacing(20) + # # main_hor_layout.setContentsMargins(20, 20, 20, 20) + + # # left column + # left_frame = QFrame() + # left_frame.setObjectName("Segment") + # left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + # left_layout = QVBoxLayout(left_frame) + # main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) + # master_data_label = HeaderCell("Stammdaten & Vermerke") + # master_data_label.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # left_layout.addWidget(master_data_label) + # # self.table_master_data = MasterDataTable() + # default_labels = tuple(self.COMPANY_FETAURES_MASTER_DATA.values()) + # self.table_master_data = MasterDataFormWidget(default_labels) + # left_layout.addWidget(self.table_master_data) + # left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) + + # # right column + # right_column_layout = QVBoxLayout() + # right_column_layout.setSpacing(20) + # main_hor_layout.addLayout(right_column_layout, stretch=1) + + # # top right area + # top_right_frame = QFrame() + # top_right_frame.setObjectName("Segment") + # top_right_frame.setSizePolicy( + # QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + # ) + # top_right_layout = QVBoxLayout(top_right_frame) + # common_consultancy_label = HeaderCell("Pauschalberatung") + # common_consultancy_label.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # top_right_layout.addWidget(common_consultancy_label) + # top_right_new_btn = QPushButton("+ Hinzufügen") + # top_right_new_btn.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # top_right_new_btn.setMinimumHeight(30) + # top_right_layout.addWidget(top_right_new_btn) + + # # bottom right area + # bottom_right_frame = QFrame() + # bottom_right_frame.setObjectName("Segment") + # bottom_right_frame.setSizePolicy( + # QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + # ) + # bottom_right_layout = QVBoxLayout(bottom_right_frame) + # individual_consultancy_label = HeaderCell("Individualberatung") + # individual_consultancy_label.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # bottom_right_layout.addWidget(individual_consultancy_label) + # bottom_right_new_btn = QPushButton("+ Hinzufügen") + # bottom_right_new_btn.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # bottom_right_new_btn.setMinimumHeight(30) + # bottom_right_layout.addWidget(bottom_right_new_btn) + + # right_column_layout.addWidget(top_right_frame) + # right_column_layout.addWidget(bottom_right_frame) + # right_column_layout.addStretch(1) + + # container_layout.addLayout(main_hor_layout) + container_layout.addStretch() + + # --- STYLE --- + self.setStyleSheet(""" + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 3px; + } + """) + + # self.setStyleSheet(""" + # /* Hintergrund des Hauptfensters */ + # QWidget { + # background-color: #f1f5f9; + # } + + # /* Styling für unsere 3 Segmente */ + # QFrame#Segment { + # background-color: #ffffff; + # border: 1px solid #e2e8f0; + # border-radius: 12px; + # padding: 15px; + # } + + # /* Text-Styling */ + # QLabel { + # color: #334155; + # font-size: 16px; + # font-weight: bold; + # } + # """) + + def load_from_request( + self, + req: ConsultingRequest, + ) -> None: + # TODO add correct loading behaviour + logger_gui.debug("[Consulting] Loading with request:\n%s", req) + + # def set_company_name(self, name: str) -> None: + # if not name: + # name = self.change_title_text.text() + # if not name: + # name = "n.v." + + # txt = f"Unternehmen: {name}" + # self.title_company_name.setText(txt) + + # def load_from_id( + # self, + # id_: InitRecId, + # ) -> None: + # assert id_ is not None, "tried to load with InitRecId None" + # logger_gui.debug(f"Called function to load with ID: {id_}") + + # if id_ < 0: + # # just clear and do nothing else + # logger_gui.debug("just reset with ID < 0") + # self.table_master_data.reset() + # self.set_company_name("") + # self.current_id = id_ + # return + + # if id_ == self.current_id: + # logger_gui.debug("ID did not change. Do nothing") + # self.current_id = id_ + # return + + # init_rec = backend.initrec_company_get_initial_recording(id_) + # ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) + # assert ma_id is not None, "no ma_id obtained" + # comp_info = backend.initrec_comp_search_get_info(ma_id) + + # data: dict[str, Any] = {} + + # for feature, value in comp_info.items(): + # if feature in self.COMPANY_FETAURES_MASTER_DATA: + # data[self.COMPANY_FETAURES_MASTER_DATA[feature]] = value + + # self.table_master_data.load_data(data) + # self.set_company_name(data["Name:"]) + # self.current_id = id_ + + def clear_layout( layout: QLayout | None, ) -> None: @@ -2850,6 +3146,10 @@ def clear_layout( class MainWindow(QMainWindow): def __init__(self): super().__init__() + + # STATE MANAGEMENT + self._history_stack: list[int] = [] + self.setWindowTitle("Master") self.resize(1800, 1000) @@ -2887,7 +3187,16 @@ class MainWindow(QMainWindow): self.company_profile = Page_CompanyProfile() self.company_profile.back_main_requested.connect(self.show_main_page) self.company_profile.back_requested.connect(self.show_page_initrec_company) + self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) + # SITE: 'Beratungsgespräch' + self.consulting_page = Page_Consulting() + self.consulting_page.back_main_requested.connect(self.show_main_page) + # self.consulting_page.back_requested.connect( + # self.show_page_company_profile_without_load + # ) + self.consulting_page.back_requested.connect(self.simple_go_back) + self.stack.addWidget(self.consulting_page) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle @@ -3038,28 +3347,53 @@ class MainWindow(QMainWindow): # 2. Auf die Detail-Seite umblättern # self.stack.setCurrentWidget(self.detail_page) + def _navigate_to_target( + self, + widget: QWidget, + ) -> None: + curr_idx = self.stack.currentIndex() + + if curr_idx != -1: + self._history_stack.append(curr_idx) + + self.stack.setCurrentWidget(widget) + + def simple_go_back(self) -> None: + if not self._history_stack: + logger_gui.info("[main program] Called >back< on empty history") + + prev_idx = self._history_stack.pop() + self.stack.setCurrentIndex(prev_idx) + def show_main_page(self): - # Zurück zur Tabelle blättern - self.stack.setCurrentWidget(self.main_page) + self._navigate_to_target(self.main_page) def show_new_entry_select(self): - self.stack.setCurrentWidget(self.new_entry_select) + self._navigate_to_target(self.new_entry_select) def show_page_initrec_company(self, reset: bool): if reset: self.initrec_company.reset_form() - self.stack.setCurrentWidget(self.initrec_company) + self._navigate_to_target(self.initrec_company) def show_page_initrec_person(self): + # TODO: similar to company with reset? self.initrec_person.reset_form() - self.stack.setCurrentWidget(self.initrec_person) + self._navigate_to_target(self.initrec_person) def show_page_company_profile( self, id_: InitRecId, ) -> None: self.company_profile.load_from_id(id_) - self.stack.setCurrentWidget(self.company_profile) + self._navigate_to_target(self.company_profile) + + def show_page_consulting( + self, + req: ConsultingRequest, + ) -> None: + self.consulting_page.load_from_request(req) + self._navigate_to_target(self.consulting_page) # --- MENÜ LOGIK --- def create_menu(self): -- 2.34.1 From 9dc26e36b51b6752903e7a1abce84870f9cf2b16 Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 30 Jun 2026 12:36:29 +0200 Subject: [PATCH 07/74] session management and prepare component interaction scheme for consultations --- src/wce_crm/gui.py | 122 ++++++++++++++++++++++++++++++++++++++----- src/wce_crm/types.py | 1 + 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 86b2e9e..aeed011 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -32,6 +32,7 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QButtonGroup, QComboBox, QCompleter, QDateEdit, @@ -48,6 +49,7 @@ from PySide6.QtWidgets import ( QMessageBox, QPlainTextEdit, QPushButton, + QRadioButton, QScrollArea, QSizePolicy, QStackedWidget, @@ -76,7 +78,7 @@ from wce_crm.logging import ( logger_get_data, logger_gui, ) -from wce_crm.types import ExtMaId, InitRecId +from wce_crm.types import ExtMaId, InitRecId, UserId if TYPE_CHECKING: ... @@ -1151,9 +1153,11 @@ class AutoForm(QWidget): def __init__( self, + session: Session, cfg: AutoFormConfig, ) -> None: super().__init__() + self.session = session self.cfg = cfg self.setStyleSheet(""" QGroupBox { @@ -2050,8 +2054,13 @@ class NewEntrySelect_view(QWidget): company_requested = Signal(bool) # Signal "Unternehmen" (bool: reset yes/no) person_requested = Signal() # Signal "Individualperson" - def __init__(self): + def __init__( + self, + session: Session, + ): super().__init__() + self.session = session + layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) @@ -2123,8 +2132,13 @@ class Page_InitRecCompany(QWidget): update_triggered = Signal() # form saved (data changed for front page) company_profile_requested = Signal(InitRecId) - def __init__(self): + def __init__( + self, + session: Session, + ): super().__init__() + self.session = session + # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2193,7 +2207,7 @@ class Page_InitRecCompany(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) - self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_UNTERNEHMEN) + self.auto_form = AutoForm(self.session, cfg=CONFIG_GRUNDERFASSUNG_UNTERNEHMEN) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) @@ -2268,8 +2282,12 @@ class Page_InitRecPerson(QWidget): back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) - def __init__(self): + def __init__( + self, + session: Session, + ): super().__init__() + self.session = session # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2328,7 +2346,7 @@ class Page_InitRecPerson(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) - self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) + self.auto_form = AutoForm(self.session, cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) @@ -2413,8 +2431,12 @@ class Page_CompanyProfile(QWidget): # update_triggered = Signal() # form saved (data changed for front page) consulting_requested = Signal(ConsultingRequest) - def __init__(self): + def __init__( + self, + session: Session, + ): super().__init__() + self.session = session # state management self.current_id: InitRecId = -1 @@ -2632,6 +2654,7 @@ class Page_CompanyProfile(QWidget): assert self.current_id > -1, ( "tried to call consulting from comp profile without set ID" ) + # TODO req = ConsultingRequest(cons_type=cons_type, un_id=self.current_id) self.consulting_requested.emit(req) @@ -2858,6 +2881,32 @@ class MasterDataFormWidget(QWidget): """) +@dc.dataclass(slots=True) +class Session: + user_id: UserId | None = None + user_name: str | None = None + + +@dc.dataclass(slots=True) +class Page_Consulting_State: + session: Session | None = None + un_id: InitRecId | None = None + pers_id: InitRecId | None = None + cons_type: ConsultingType | None = None + + +@dc.dataclass(slots=True) +class ConsultingSession: + user_id: UserId + user_name: str + timestamp: datetime.datetime + partner: str + interaction_scheme: str + topic: str # TODO should be linked to something, target unclear (need specification by customer) + notes: str + response: str + + class Page_Consulting(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal(bool) # back to init rec (bool: reset yes/no) @@ -2865,10 +2914,16 @@ class Page_Consulting(QWidget): # more like listening to 'child' ("Beratungen") # update_triggered = Signal() # form saved (data changed for front page) - def __init__(self): + def __init__( + self, + session: Session, + ): super().__init__() + self.session = session # state management + self.STATE = Page_Consulting_State() + # self.current_id: InitRecId = -1 # self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { # "ma_unternehmensname": "Name:", @@ -2965,6 +3020,19 @@ class Page_Consulting(QWidget): # container_layout.addWidget(self.title_company_name) container_layout.addWidget(QLabel("PLATZHALTER")) + self.type_pauschal_btn = QRadioButton("Pauschalberatung") + self.type_individual_btn = QRadioButton("Individualberatung") + + self.type_pauschal_btn.setChecked(True) + + self.type_group = QButtonGroup() + self.type_group.addButton(self.type_pauschal_btn, 0) + self.type_group.addButton(self.type_individual_btn, 1) + self.type_group.idClicked.connect(self.type_consulting_changed) + + container_layout.addWidget(self.type_pauschal_btn) + container_layout.addWidget(self.type_individual_btn) + container_layout.addSpacing(20) # separation in left and right @@ -3071,12 +3139,38 @@ class Page_Consulting(QWidget): # } # """) + # TODO placeholder, check if needed + def _sync_state_to_ui(self) -> None: ... + + def _sync_ui_to_state(self) -> None: ... + + def type_consulting_changed( + self, + button_id: int, + ) -> None: + logger_gui.debug("[Consultin Page] Trigger type buttons...") + + cons_type: ConsultingType + if button_id == 0: + cons_type = ConsultingType.PAUSCHAL + elif button_id == 1: + cons_type = ConsultingType.INDIVIUAL + else: + raise RuntimeError(f"No consulting type defined for button id: {button_id}") + + logger_gui.debug("[Consulting Page] Selected cons_type: %s", cons_type) + self.STATE.cons_type = cons_type + def load_from_request( self, req: ConsultingRequest, ) -> None: # TODO add correct loading behaviour + # TODO add validation (correct type with necessary information) logger_gui.debug("[Consulting] Loading with request:\n%s", req) + self.STATE.un_id = req.un_id + self.STATE.pers_id = req.pers_id + self.STATE.cons_type = req.cons_type # def set_company_name(self, name: str) -> None: # if not name: @@ -3148,6 +3242,7 @@ class MainWindow(QMainWindow): super().__init__() # STATE MANAGEMENT + self.session = Session(user_id=42, user_name="TEST-USER") self._history_stack: list[int] = [] self.setWindowTitle("Master") @@ -3165,32 +3260,32 @@ class MainWindow(QMainWindow): self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' - self.new_entry_select = NewEntrySelect_view() + self.new_entry_select = NewEntrySelect_view(self.session) self.new_entry_select.back_requested.connect(self.show_main_page) self.new_entry_select.company_requested.connect(self.show_page_initrec_company) self.new_entry_select.person_requested.connect(self.show_page_initrec_person) self.stack.addWidget(self.new_entry_select) # SITE: 'Grunderfassung Unternehmen' - self.initrec_company = Page_InitRecCompany() + self.initrec_company = Page_InitRecCompany(self.session) self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) self.initrec_company.update_triggered.connect(self.update_grid) self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' - self.initrec_person = Page_InitRecPerson() + self.initrec_person = Page_InitRecPerson(self.session) self.initrec_person.back_main_requested.connect(self.show_main_page) self.initrec_person.back_requested.connect(self.show_new_entry_select) self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' - self.company_profile = Page_CompanyProfile() + self.company_profile = Page_CompanyProfile(self.session) self.company_profile.back_main_requested.connect(self.show_main_page) self.company_profile.back_requested.connect(self.show_page_initrec_company) self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) # SITE: 'Beratungsgespräch' - self.consulting_page = Page_Consulting() + self.consulting_page = Page_Consulting(self.session) self.consulting_page.back_main_requested.connect(self.show_main_page) # self.consulting_page.back_requested.connect( # self.show_page_company_profile_without_load @@ -3366,6 +3461,7 @@ class MainWindow(QMainWindow): self.stack.setCurrentIndex(prev_idx) def show_main_page(self): + self._history_stack.clear() self._navigate_to_target(self.main_page) def show_new_entry_select(self): diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index edec434..38575a7 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, TypeAlias if TYPE_CHECKING: import polars as pl +UserId: TypeAlias = int InitRecId: TypeAlias = int ExtMaId: TypeAlias = int ExtAnId: TypeAlias = int -- 2.34.1 From 10c8b79cb31c8376ac442177563e7a214d92f72d Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 30 Jun 2026 16:15:06 +0200 Subject: [PATCH 08/74] prepare insertion of sessions --- src/wce_crm/gui.py | 146 ++++++++++++++++++++------------------------- 1 file changed, 64 insertions(+), 82 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index aeed011..e598691 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -2887,14 +2887,6 @@ class Session: user_name: str | None = None -@dc.dataclass(slots=True) -class Page_Consulting_State: - session: Session | None = None - un_id: InitRecId | None = None - pers_id: InitRecId | None = None - cons_type: ConsultingType | None = None - - @dc.dataclass(slots=True) class ConsultingSession: user_id: UserId @@ -2907,6 +2899,15 @@ class ConsultingSession: response: str +@dc.dataclass(slots=True) +class Page_Consulting_State: + session: Session | None = None + un_id: InitRecId | None = None + pers_id: InitRecId | None = None + cons_type: ConsultingType | None = None + cons_sessions: list[ConsultingSession] = dc.field(default_factory=list) + + class Page_Consulting(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal(bool) # back to init rec (bool: reset yes/no) @@ -3016,10 +3017,9 @@ class Page_Consulting(QWidget): container_layout.addSpacing(30) - # self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) - # container_layout.addWidget(self.title_company_name) - container_layout.addWidget(QLabel("PLATZHALTER")) - + type_group_box = QGroupBox("Typ") + type_layout = QVBoxLayout(type_group_box) + container_layout.addWidget(type_group_box) self.type_pauschal_btn = QRadioButton("Pauschalberatung") self.type_individual_btn = QRadioButton("Individualberatung") @@ -3030,81 +3030,26 @@ class Page_Consulting(QWidget): self.type_group.addButton(self.type_individual_btn, 1) self.type_group.idClicked.connect(self.type_consulting_changed) - container_layout.addWidget(self.type_pauschal_btn) - container_layout.addWidget(self.type_individual_btn) + type_layout.addWidget(self.type_pauschal_btn) + type_layout.addWidget(self.type_individual_btn) container_layout.addSpacing(20) - # separation in left and right - # main_hor_layout = QHBoxLayout() - # main_hor_layout.setSpacing(20) - # # main_hor_layout.setContentsMargins(20, 20, 20, 20) + self.layout_sessions = QVBoxLayout() + self.layout_sessions.setContentsMargins(0, 0, 0, 0) + container_layout.addLayout(self.layout_sessions) + self.btn_add_cons_session = QPushButton("+ Hinzufügen") + self.btn_add_cons_session.setStyleSheet( + "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" + ) + self.btn_add_cons_session.clicked.connect(self.add_cons_entry) + self.layout_sessions.addWidget(self.btn_add_cons_session) + self.layout_sessions.addStretch(1) - # # left column - # left_frame = QFrame() - # left_frame.setObjectName("Segment") - # left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) - # left_layout = QVBoxLayout(left_frame) - # main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) - # master_data_label = HeaderCell("Stammdaten & Vermerke") - # master_data_label.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # left_layout.addWidget(master_data_label) - # # self.table_master_data = MasterDataTable() - # default_labels = tuple(self.COMPANY_FETAURES_MASTER_DATA.values()) - # self.table_master_data = MasterDataFormWidget(default_labels) - # left_layout.addWidget(self.table_master_data) - # left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) + self.cons_entries: list[QWidget] = [] - # # right column - # right_column_layout = QVBoxLayout() - # right_column_layout.setSpacing(20) - # main_hor_layout.addLayout(right_column_layout, stretch=1) + # self.layout_sessions.addStretch() - # # top right area - # top_right_frame = QFrame() - # top_right_frame.setObjectName("Segment") - # top_right_frame.setSizePolicy( - # QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum - # ) - # top_right_layout = QVBoxLayout(top_right_frame) - # common_consultancy_label = HeaderCell("Pauschalberatung") - # common_consultancy_label.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # top_right_layout.addWidget(common_consultancy_label) - # top_right_new_btn = QPushButton("+ Hinzufügen") - # top_right_new_btn.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # top_right_new_btn.setMinimumHeight(30) - # top_right_layout.addWidget(top_right_new_btn) - - # # bottom right area - # bottom_right_frame = QFrame() - # bottom_right_frame.setObjectName("Segment") - # bottom_right_frame.setSizePolicy( - # QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum - # ) - # bottom_right_layout = QVBoxLayout(bottom_right_frame) - # individual_consultancy_label = HeaderCell("Individualberatung") - # individual_consultancy_label.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # bottom_right_layout.addWidget(individual_consultancy_label) - # bottom_right_new_btn = QPushButton("+ Hinzufügen") - # bottom_right_new_btn.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # bottom_right_new_btn.setMinimumHeight(30) - # bottom_right_layout.addWidget(bottom_right_new_btn) - - # right_column_layout.addWidget(top_right_frame) - # right_column_layout.addWidget(bottom_right_frame) - # right_column_layout.addStretch(1) - - # container_layout.addLayout(main_hor_layout) container_layout.addStretch() # --- STYLE --- @@ -3139,6 +3084,43 @@ class Page_Consulting(QWidget): # } # """) + def add_cons_entry(self) -> None: + logger_gui.debug("[Consulting Page] Adding entry...") + + row_widget = QWidget() + entry_row = QHBoxLayout(row_widget) + entry_fields = QHBoxLayout() + entry_row.addLayout(entry_fields) + + txt1 = QLineEdit() + txt2 = QPlainTextEdit() + txt2.setMaximumHeight(100) + entry_fields.addWidget(txt1) + entry_fields.addWidget(txt2) + + del_btn = QPushButton("🗑️") + del_btn.setFixedSize(30, 30) + # Lambda with default parameter to delete exactly this(!) box + del_btn.clicked.connect( + lambda checked=False, widget=row_widget: self.remove_cons_entry(widget) + ) + entry_row.addWidget(del_btn) + + row_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # row_widget.setMaximumHeight(200) + + self.cons_entries.append(row_widget) + self.layout_sessions.insertWidget(self.layout_sessions.count() - 2, row_widget) + + def remove_cons_entry( + self, + row_widget: QWidget, + ) -> None: + logger_gui.debug("[Consulting Page]: Delete clicked...") + self.layout_sessions.removeWidget(row_widget) + row_widget.deleteLater() + self.cons_entries.remove(row_widget) + # TODO placeholder, check if needed def _sync_state_to_ui(self) -> None: ... @@ -3148,7 +3130,7 @@ class Page_Consulting(QWidget): self, button_id: int, ) -> None: - logger_gui.debug("[Consultin Page] Trigger type buttons...") + logger_gui.debug("[Consulting Page] Trigger type buttons...") cons_type: ConsultingType if button_id == 0: -- 2.34.1 From 1d353d863e036e5f9eeb35cfd64d2ead7f0f3e6a Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 1 Jul 2026 16:36:15 +0200 Subject: [PATCH 09/74] added page for consulting sessions with dynamic table --- src/wce_crm/gui.py | 509 +++++++++++++++++++++++++++++++++-------- src/wce_crm/logging.py | 3 + 2 files changed, 414 insertions(+), 98 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index e598691..e5f1d0f 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -28,7 +28,7 @@ from PySide6.QtCore import ( Signal, qInstallMessageHandler, ) -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QFont from PySide6.QtWidgets import ( QAbstractItemView, QApplication, @@ -77,6 +77,7 @@ from wce_crm.logging import ( logger_auto_form, logger_get_data, logger_gui, + logger_page_consulting, ) from wce_crm.types import ExtMaId, InitRecId, UserId @@ -2883,29 +2884,40 @@ class MasterDataFormWidget(QWidget): @dc.dataclass(slots=True) class Session: - user_id: UserId | None = None - user_name: str | None = None + user_id: UserId + user_name: str @dc.dataclass(slots=True) -class ConsultingSession: - user_id: UserId - user_name: str - timestamp: datetime.datetime - partner: str - interaction_scheme: str - topic: str # TODO should be linked to something, target unclear (need specification by customer) - notes: str - response: str +class Page_Consulting_ConsultingSession_State: + user_id: UserId # must be set with the session (always known) + user_name: str # must be set with the session (always known) + cons_session_id: int | None = None # known and not known entries + timestamp: datetime.datetime = dc.field( + default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) + ) + partner: str = "" + interaction_scheme: str = "" # set string like in other dropdowns or use integer (enum) + topic: str = "" # TODO should be linked to something, target unclear (need specification by customer) + notes: str = "" + response: str = "" + + +@dc.dataclass(slots=True) +class Page_Consulting_Table_State: + rows: list[Page_Consulting_ConsultingSession] = dc.field(default_factory=list) + row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) @dc.dataclass(slots=True) class Page_Consulting_State: - session: Session | None = None + session: Session un_id: InitRecId | None = None pers_id: InitRecId | None = None cons_type: ConsultingType | None = None - cons_sessions: list[ConsultingSession] = dc.field(default_factory=list) + cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( + default_factory=list + ) class Page_Consulting(QWidget): @@ -2920,10 +2932,8 @@ class Page_Consulting(QWidget): session: Session, ): super().__init__() - self.session = session - # state management - self.STATE = Page_Consulting_State() + self.STATE = Page_Consulting_State(session=session) # self.current_id: InitRecId = -1 # self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { @@ -2951,7 +2961,7 @@ class Page_Consulting(QWidget): # --- HEADER --- header_container = QWidget() header_container.setMinimumWidth(700) - header_container.setMaximumWidth(1200) + header_container.setMaximumWidth(1500) header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) @@ -2984,7 +2994,7 @@ class Page_Consulting(QWidget): scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setMinimumWidth(700) - scroll_area.setMaximumWidth(1200) + scroll_area.setMaximumWidth(1500) scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # optional: remove frame to look more modern scroll_area.setFrameShape(QFrame.Shape.NoFrame) @@ -3009,6 +3019,9 @@ class Page_Consulting(QWidget): # layout_change_title.addWidget(change_title_btn) # change_title_btn.clicked.connect(self.set_company_name) # container_layout.addLayout(layout_change_title) + btn_get_data_table = QPushButton("Call 'get_data' from table") + btn_get_data_table.clicked.connect(self._debug_get_data_table) + container_layout.addWidget(btn_get_data_table) separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) @@ -3033,24 +3046,28 @@ class Page_Consulting(QWidget): type_layout.addWidget(self.type_pauschal_btn) type_layout.addWidget(self.type_individual_btn) - container_layout.addSpacing(20) + # container_layout.addSpacing(50) + table_state = Page_Consulting_Table_State() + self.table_sessions = Page_Consulting_Table(site_state=self.STATE, state=table_state) + container_layout.addWidget(self.table_sessions) + # container_layout.addSpacing(50) - self.layout_sessions = QVBoxLayout() - self.layout_sessions.setContentsMargins(0, 0, 0, 0) - container_layout.addLayout(self.layout_sessions) - self.btn_add_cons_session = QPushButton("+ Hinzufügen") - self.btn_add_cons_session.setStyleSheet( - "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" - ) - self.btn_add_cons_session.clicked.connect(self.add_cons_entry) - self.layout_sessions.addWidget(self.btn_add_cons_session) - self.layout_sessions.addStretch(1) + # self.layout_sessions = QVBoxLayout() + # self.layout_sessions.setContentsMargins(0, 0, 0, 0) + # container_layout.addLayout(self.layout_sessions) + # self.btn_add_cons_session = QPushButton("+ Hinzufügen") + # self.btn_add_cons_session.setStyleSheet( + # "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" + # ) + # self.btn_add_cons_session.clicked.connect(self.add_cons_entry) + # self.layout_sessions.addWidget(self.btn_add_cons_session) + # self.layout_sessions.addStretch(1) - self.cons_entries: list[QWidget] = [] + # self.cons_entries: list[QWidget] = [] # self.layout_sessions.addStretch() - container_layout.addStretch() + # container_layout.addStretch() # --- STYLE --- self.setStyleSheet(""" @@ -3084,45 +3101,53 @@ class Page_Consulting(QWidget): # } # """) - def add_cons_entry(self) -> None: - logger_gui.debug("[Consulting Page] Adding entry...") + # def add_cons_entry(self) -> None: + # logger_gui.debug("[Consulting Page] Adding entry...") - row_widget = QWidget() - entry_row = QHBoxLayout(row_widget) - entry_fields = QHBoxLayout() - entry_row.addLayout(entry_fields) + # row_widget = QWidget() + # entry_row = QHBoxLayout(row_widget) + # entry_fields = QHBoxLayout() + # entry_row.addLayout(entry_fields) - txt1 = QLineEdit() - txt2 = QPlainTextEdit() - txt2.setMaximumHeight(100) - entry_fields.addWidget(txt1) - entry_fields.addWidget(txt2) + # txt1 = QLineEdit() + # txt2 = QPlainTextEdit() + # txt2.setMaximumHeight(100) + # entry_fields.addWidget(txt1) + # entry_fields.addWidget(txt2) - del_btn = QPushButton("🗑️") - del_btn.setFixedSize(30, 30) - # Lambda with default parameter to delete exactly this(!) box - del_btn.clicked.connect( - lambda checked=False, widget=row_widget: self.remove_cons_entry(widget) - ) - entry_row.addWidget(del_btn) + # del_btn = QPushButton("🗑️") + # del_btn.setFixedSize(30, 30) + # # Lambda with default parameter to delete exactly this(!) box + # del_btn.clicked.connect( + # lambda checked=False, widget=row_widget: self.remove_cons_entry(widget) + # ) + # entry_row.addWidget(del_btn) - row_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - # row_widget.setMaximumHeight(200) + # row_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # # row_widget.setMaximumHeight(200) - self.cons_entries.append(row_widget) - self.layout_sessions.insertWidget(self.layout_sessions.count() - 2, row_widget) + # self.cons_entries.append(row_widget) + # self.layout_sessions.insertWidget(self.layout_sessions.count() - 2, row_widget) - def remove_cons_entry( - self, - row_widget: QWidget, - ) -> None: - logger_gui.debug("[Consulting Page]: Delete clicked...") - self.layout_sessions.removeWidget(row_widget) - row_widget.deleteLater() - self.cons_entries.remove(row_widget) + # def remove_cons_entry( + # self, + # row_widget: QWidget, + # ) -> None: + # logger_gui.debug("[Consulting Page]: Delete clicked...") + # self.layout_sessions.removeWidget(row_widget) + # row_widget.deleteLater() + # self.cons_entries.remove(row_widget) + + def _debug_get_data_table(self) -> None: + self.table_sessions.get_data() # TODO placeholder, check if needed - def _sync_state_to_ui(self) -> None: ... + def _sync_state_to_ui(self) -> None: + match self.STATE.cons_type: + case ConsultingType.PAUSCHAL: + self.type_pauschal_btn.setChecked(True) + case ConsultingType.INDIVIUAL: + self.type_individual_btn.setChecked(True) def _sync_ui_to_state(self) -> None: ... @@ -3154,49 +3179,337 @@ class Page_Consulting(QWidget): self.STATE.pers_id = req.pers_id self.STATE.cons_type = req.cons_type - # def set_company_name(self, name: str) -> None: - # if not name: - # name = self.change_title_text.text() - # if not name: - # name = "n.v." + self._sync_state_to_ui() - # txt = f"Unternehmen: {name}" - # self.title_company_name.setText(txt) - # def load_from_id( - # self, - # id_: InitRecId, - # ) -> None: - # assert id_ is not None, "tried to load with InitRecId None" - # logger_gui.debug(f"Called function to load with ID: {id_}") +class Page_Consulting_ConsultingSession(QWidget): + """child of 'Page_Consulting_DynamicTable'""" - # if id_ < 0: - # # just clear and do nothing else - # logger_gui.debug("just reset with ID < 0") - # self.table_master_data.reset() - # self.set_company_name("") - # self.current_id = id_ - # return + delete_request = Signal(QWidget) - # if id_ == self.current_id: - # logger_gui.debug("ID did not change. Do nothing") - # self.current_id = id_ - # return + def __init__( + self, + site_state: Page_Consulting_State, + state: Page_Consulting_ConsultingSession_State, + ) -> None: + super().__init__() + assert isinstance(site_state, Page_Consulting_State) + assert isinstance(state, Page_Consulting_ConsultingSession_State) + self.SITE_STATE = site_state + self.STATE = state + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 2, 0, 2) # Sehr kompakt + layout.setSpacing(10) + layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + ROW_HEIGHT = 70 - # init_rec = backend.initrec_company_get_initial_recording(id_) - # ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) - # assert ma_id is not None, "no ma_id obtained" - # comp_info = backend.initrec_comp_search_get_info(ma_id) + self.user_field = QLineEdit( + placeholderText="wird über aktiven Nutzer bei Anlage gesetzt" + ) + self.timestamp = QLineEdit() # TODO change to custom date field - # data: dict[str, Any] = {} + self.contact = QLineEdit(placeholderText="Freitext") + self.contact_type = QLineEdit(placeholderText="sollte Dropdown sein") # Dropdown + self.crm_link = QLineEdit(placeholderText="CRM Links") - # for feature, value in comp_info.items(): - # if feature in self.COMPANY_FETAURES_MASTER_DATA: - # data[self.COMPANY_FETAURES_MASTER_DATA[feature]] = value + self.plain_text = QPlainTextEdit() + self.response = QPlainTextEdit() - # self.table_master_data.load_data(data) - # self.set_company_name(data["Name:"]) - # self.current_id = id_ + self.btn_delete = QPushButton("✕") + self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen + self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) + + self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;") + self.btn_delete.clicked.connect(lambda: self.delete_request.emit(self)) + self.btn_delete.setFixedWidth(30) + + widgets = ( + self.user_field, + self.timestamp, + self.contact, + self.contact_type, + self.crm_link, + self.plain_text, + self.response, + self.btn_delete, + ) + for widget in widgets: + widget.setFixedHeight(ROW_HEIGHT) + layout.addWidget(widget) + + # column stretching: must be matched with header + num_widgets = len(widgets) + for i, widget in enumerate(widgets): + stretch: int + if i == (num_widgets - 1): + stretch = 0 + else: + if "plaintext" in widget.__class__.__name__.lower(): + stretch = 2 + else: + stretch = 1 + + layout.setStretch(i, stretch) + + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self._sync_state_to_gui() # ?? only needed if entry is from database? + + def _sync_state_to_gui(self) -> None: + # TODO set correct timestamp + logger_page_consulting.debug( + "[Page Consulting -- Consulting Session]: State = %s", self.STATE + ) + set_widget_value(self.user_field, self.STATE.user_name) + set_widget_value(self.timestamp, self.STATE.timestamp) + set_widget_value(self.contact, self.STATE.partner) + set_widget_value(self.contact_type, self.STATE.interaction_scheme) + set_widget_value(self.crm_link, self.STATE.topic) + set_widget_value(self.plain_text, self.STATE.notes) + set_widget_value(self.response, self.STATE.response) + + def _sync_gui_to_state(self) -> None: + # TODO: get timestamp from custom field self.STATE.timestamp + self.STATE.timestamp = datetime.datetime.strptime( + self.timestamp.text(), "%d.%m.%Y %H:%M:%S" + ) + self.STATE.partner = self.contact.text() + self.STATE.interaction_scheme = self.contact_type.text() + self.STATE.topic = self.crm_link.text() # TODO: change to correct link + self.STATE.notes = self.plain_text.toPlainText() + self.STATE.response = self.response.toPlainText() + + def get_data(self) -> Page_Consulting_ConsultingSession_State: + self._sync_gui_to_state() + + return self.STATE + + +class Page_Consulting_Table(QWidget): + """child of 'Page_Consulting'""" + + def __init__( + self, + site_state: Page_Consulting_State, + state: Page_Consulting_Table_State, + ) -> None: + super().__init__() + self.setWindowTitle("Native Dynamische Liste") + self.resize(600, 400) + + self.STATE = state + self.SITE_STATE = site_state + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 15, 0, 15) + main_layout.setSpacing(10) + + # --- BUTTON (ADD) --- + self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen") + self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_add.setObjectName("BlueAddButton") # ID vergeben + main_layout.addWidget(self.btn_add) + self.btn_add.clicked.connect(lambda: self.add_row(None)) + + self.btn_add.setStyleSheet(""" + #BlueAddButton { + background-color: #007acc; + color: white; + border: none; + border-radius: 6px; + padding: 8px 15px; + font-weight: bold; + } + #BlueAddButton:hover { + background-color: #0098ff; + } + """) + + # --- HEADER --- + header_widget = QWidget() + header_layout = QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 5, 0, 5) + header_layout.setSpacing(10) + + lbl_user_field = QLabel("Wer") + lbl_timestamp = QLabel("Wann") + lbl_contact = QLabel("Mit wem") + lbl_contact_type = QLabel("Wie") + lbl_crm_link = QLabel("Was") + lbl_plain_text = QLabel("Anmerkungen") + lbl_response = QLabel("Rückmeldung") + lbl_action_delete = QLabel("") + lbl_action_delete.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl_action_delete.setFixedWidth(30) + + labels = ( + lbl_user_field, + lbl_timestamp, + lbl_contact, + lbl_contact_type, + lbl_crm_link, + lbl_plain_text, + lbl_response, + lbl_action_delete, + ) + + header_font = QFont() + header_font.setBold(True) + for lbl in labels: + lbl.setFont(header_font) + lbl.setStyleSheet("color: #666666;") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + header_layout.addWidget(lbl) + + plaintext_labels = {lbl_plain_text, lbl_response} + + num_labels = len(labels) + for i, widget in enumerate(labels): + stretch: int + if i == (num_labels - 1): + stretch = 0 + else: + if widget in plaintext_labels: + stretch = 2 + else: + stretch = 1 + + header_layout.setStretch(i, stretch) + + main_layout.addWidget(header_widget) + + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + main_layout.addWidget(separator) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QScrollArea.Shape.NoFrame) + + self.container_widget = QWidget() + self.container_layout = QVBoxLayout(self.container_widget) + self.container_layout.setContentsMargins(0, 5, 0, 5) + self.container_layout.setSpacing(6) + self.container_layout.addStretch() + + self.scroll_area.setWidget(self.container_widget) + main_layout.addWidget(self.scroll_area) + + # --- STYLING (QSS) --- + # self.setStyleSheet(""" + # QWidget { + # background-color: #1e1e24; + # color: #e0e0e6; + # font-family: 'Segoe UI', sans-serif; + # font-size: 13px; + # } + # #HeaderWidget QLabel { + # font-weight: bold; + # color: #888892; + # font-size: 11px; + # letter-spacing: 1px; + # } + # QLineEdit { + # background-color: #2a2a32; + # border: 1px solid #3f3f4a; + # border-radius: 6px; + # padding: 6px 10px; + # color: white; + # } + # QLineEdit:focus { + # border: 1px solid #007acc; + # } + # QPushButton { + # background-color: #007acc; + # color: white; + # border: none; + # border-radius: 6px; + # padding: 8px 15px; + # font-weight: bold; + # } + # QPushButton:hover { + # background-color: #0098ff; + # } + # #DeleteButton { + # background-color: transparent; + # color: #ff5555; + # border: 1px solid #3f3f4a; + # font-size: 14px; + # padding: 4px; + # } + # #DeleteButton:hover { + # background-color: #ff3333; + # color: white; + # border-color: #ff3333; + # } + # QScrollArea { + # background-color: transparent; + # } + # """) + + if DEBUG: + # add rows for debugging + self.add_row() + self.add_row() + + def add_row( + self, + row_state: Page_Consulting_ConsultingSession_State | None = None, + ) -> None: + if row_state is None: + row_state = Page_Consulting_ConsultingSession_State( + user_id=self.SITE_STATE.session.user_id, + user_name=self.SITE_STATE.session.user_name, + ) + + row = Page_Consulting_ConsultingSession(site_state=self.SITE_STATE, state=row_state) + row.delete_request.connect(self.remove_row) + + index = self.container_layout.count() - 1 + self.container_layout.insertWidget(index, row) + self.STATE.rows.append(row) + + def remove_row( + self, + table_row_widget: Page_Consulting_ConsultingSession, + ) -> None: + self.container_layout.removeWidget(table_row_widget) + self.STATE.rows.remove(table_row_widget) + table_row_widget.deleteLater() + + def reset(self) -> None: + while self.STATE.rows: + self.remove_row(self.STATE.rows[0]) + + def _sync_state_to_gui(self) -> None: + for row_state in self.STATE.row_states: + self.add_row(row_state) + + def _sync_gui_to_state(self) -> None: + row_data: list[Page_Consulting_ConsultingSession_State] = [] + + for i in range(self.container_layout.count() - 1): + item = self.container_layout.itemAt(i) + assert item, "item not found" + widget = item.widget() + assert widget, "no widget obtained" + + if isinstance(widget, Page_Consulting_ConsultingSession): + row_data.append(widget.get_data()) + + assert len(row_data) == len(self.STATE.rows), "mismatch in row count and data length" + self.STATE.row_states = row_data + + def get_data(self) -> Page_Consulting_Table_State: + logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") + self._sync_gui_to_state() + + logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) + + return self.STATE + + +#################################################################### def clear_layout( diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index a9a36ca..9d0cfd7 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -37,6 +37,9 @@ logger_get_data.setLevel(logging.DEBUG) logger_auto_form = logger_gui.getChild("get_data_auto_form") logger_auto_form.setLevel(logging.DEBUG) +logger_page_consulting = logger_gui.getChild("page_consulting") +logger_page_consulting.setLevel(logging.DEBUG) + # ** Backend logger_back = logger_base.getChild("backend") logger_back.setLevel(logging.DEBUG) -- 2.34.1 From ae48f72500753118a1cad703af664e8ed55bddce Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 3 Jul 2026 10:32:39 +0200 Subject: [PATCH 10/74] implement custom datetime and date widgets based on customer requirements --- src/wce_crm/constants.py | 4 + src/wce_crm/form_defs.py | 2 + src/wce_crm/gui.py | 461 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 461 insertions(+), 6 deletions(-) diff --git a/src/wce_crm/constants.py b/src/wce_crm/constants.py index 70ac77d..8c9f695 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import zoneinfo from pathlib import Path from typing import Final @@ -25,3 +26,6 @@ class Config: PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log") LOG_FILENAME: str = "dopt.log" ALEMBIC_PATH: Path = BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic") + + +TIMEZONE_CEST: Final[zoneinfo.ZoneInfo] = zoneinfo.ZoneInfo("Europe/Berlin") diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index c000d8c..c58f359 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -16,7 +16,9 @@ class FormFieldType(enum.StrEnum): TEXT = enum.auto() LONGTEXT = enum.auto() DATE = enum.auto() + DATE_FLEXIBLE = enum.auto() DATETIME = enum.auto() + DATETIME_FLEXIBLE = enum.auto() DROPDOWN = enum.auto() EXTENDED_DROPDOWN = enum.auto() DYNAMIC_LIST = enum.auto() diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index e5f1d0f..97f168b 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -15,14 +15,17 @@ from pprint import pformat from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast from typing_extensions import override +from dopt_basics import datetime as dopt_dt from pydantic import ( ValidationError, ) from PySide6.QtCore import ( QDate, + QDateTime, QEvent, QObject, Qt, + QTime, QTimer, QtMsgType, Signal, @@ -33,6 +36,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QButtonGroup, + QCalendarWidget, QComboBox, QCompleter, QDateEdit, @@ -61,6 +65,7 @@ from PySide6.QtWidgets import ( import wce_crm.constants from wce_crm.backend import backend +from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, FlatBaseModel, @@ -406,6 +411,54 @@ def _build_ui_recursively( else: parent_layout.addRow(field.label, widget) + case FormFieldType.DATE_FLEXIBLE: + widget = FlexibleDateInput() + if field.fill_value: + set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{field.fill_value}<" + ) + widget.set_date(set_date) + + if field.readonly: + widget.setReadOnly(True) + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + case FormFieldType.DATETIME_FLEXIBLE: + widget = FlexibleDateTimeInput() + if field.fill_value: + set_datetime = QDateTime.fromString(field.fill_value, "dd.MM.yyyy hh:mm") + if not set_datetime.isValid(): + raise ValueError( + f"Could not parse date field value >{field.fill_value}<" + ) + widget.set_datetime(set_datetime) + + if field.readonly: + widget.setReadOnly(True) + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + case FormFieldType.DROPDOWN: widget = QComboBox() widget.addItem(DROPDOWN_DEFAULT, None) @@ -576,6 +629,24 @@ def reset_form( widget.setDate(set_date) else: widget.setDate(QDate.currentDate()) + elif isinstance(widget, FlexibleDateInput): + widget.reset() + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_date(set_date) + elif isinstance(widget, FlexibleDateTimeInput): + widget.reset() + if form_field.fill_value: + set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm") + if not set_datetime.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_datetime(set_datetime) elif isinstance(widget, QComboBox): if form_field.fill_value: widget.setCurrentText(form_field.fill_value) @@ -699,6 +770,10 @@ def get_form_data( elif isinstance(widget, QDateEdit): qt_date = widget.date() value = qt_date.toPython() + elif isinstance(widget, FlexibleDateInput): + value = widget.get_pydate() + elif isinstance(widget, FlexibleDateTimeInput): + value = widget.get_pydatetime() elif isinstance(widget, QComboBox): value = widget.currentData() elif isinstance( @@ -754,7 +829,18 @@ def set_widget_value( if not set_date.isValid(): raise ValueError(f"Could not parse date field value >{value}<") widget.setDate(set_date) - + elif isinstance(widget, FlexibleDateInput): + assert isinstance(value, datetime.datetime) + try: + widget.set_pydate(value, raise_error=True) + except ValueError: + raise ValueError(f"Could not parse date field value >{value}<") + elif isinstance(widget, FlexibleDateTimeInput): + assert isinstance(value, datetime.datetime) + try: + widget.set_pydatetime(value, raise_error=True) + except ValueError: + raise ValueError(f"Could not parse datetime field value >{value}<") elif isinstance(widget, QComboBox): index = widget.findData(value) if index >= 0: @@ -847,6 +933,16 @@ def validate_form_data( if widget.currentData() is not None: continue error_post = True + elif isinstance(widget, FlexibleDateInput): + date = widget.get_date() + if date and date.isValid(): + continue + error_post = True + elif isinstance(widget, FlexibleDateTimeInput): + date = widget.get_datetime() + if date and date.isValid(): + continue + error_post = True elif isinstance( widget, ( @@ -1477,6 +1573,355 @@ class AutoForm(QWidget): set_form_data(self.widget_registry, data) +class FlexibleDateInput(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._read_only = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # simple text field allows for easy editing and deletion + self.line_edit = QLineEdit() + self.line_edit.setPlaceholderText("TT.MM.JJJJ") + self.line_edit.setInputMask("99.99.9999;_") # force date structure + + self.button = QPushButton("📅") + self.button.setFixedWidth(30) + + layout.addWidget(self.line_edit) + layout.addWidget(self.button) + + self.calendar = QCalendarWidget() + self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window + self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + self.calendar.setGridVisible(True) + self.calendar.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + + self.button.clicked.connect(self.show_calendar) + self.calendar.activated.connect(self.set_date) + # signal to validate + self.line_edit.editingFinished.connect(self.validate_input) + self.line_edit.installEventFilter(self) + + self.setReadOnly(False) + + def eventFilter( + self, + watched: QObject, + event: QEvent, + ): + # press enter: direct validation + if watched == self.line_edit: + if event.type() == QEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore + self.validate_input() + return True # mark event as processed + elif event.type() == QEvent.Type.FocusOut: + self.validate_input() + # no return to not block focus change + return super().eventFilter(watched, event) + + def show_calendar(self) -> None: + if self._read_only: + return + pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft()) + self.calendar.move(pos) + self.calendar.show() + + def isReadOnly(self) -> bool: + return self._read_only + + def setReadOnly( + self, + read_only: bool, + ): + self._read_only = read_only + self.line_edit.setReadOnly(read_only) + self.button.setEnabled(not read_only) + + current_text = self.line_edit.text() + + if read_only: + self.line_edit.setInputMask("") + self.line_edit.setPlaceholderText("kein Datum eingetragen") + pure = ( + current_text.replace(".", "") + .replace(":", "") + .replace(" ", "") + .replace("_", "") + .strip() + ) + cleaned_date = current_text if pure else "" + self.line_edit.setText(cleaned_date) + self.line_edit.setProperty("styleClass", "stempel") + else: + self.line_edit.setInputMask("99.99.9999;_") + self.line_edit.setPlaceholderText("TT.MM.JJJJ") + + def validate_input(self) -> None: + logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation") + if self._read_only: + return + + focused_widget = QApplication.focusWidget() + if focused_widget == self.button or focused_widget == self.calendar: + return + + text = self.line_edit.text() + # field empty + if not text.replace(".", "").replace("_", "").strip(): + self.line_edit.setStyleSheet("") + return + + # if user wrote input + date = QDate.fromString(text, "dd.MM.yyyy") + + if not date.isValid(): + self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;") + self.line_edit.selectAll() + # optional: keep focus in field to directly correct entry + # self.line_edit.setFocus() + else: + self.line_edit.setStyleSheet("") + + def set_date( + self, + date: QDate, + ) -> None: + if self._read_only: + return + + if date and date.isValid(): + self.line_edit.setText(date.toString("dd.MM.yyyy")) + else: + self.line_edit.clear() + + self.line_edit.setText(date.toString("dd.MM.yyyy")) + self.calendar.hide() + + def set_pydate( + self, + date: datetime.datetime, + raise_error: bool = False, + ) -> None: + date_to_set = QDate(date.year, date.month, date.day) + if raise_error and not date_to_set.isValid(): + raise ValueError("Could not parse date object correctly") + + self.set_date(date_to_set) + + def get_date(self) -> QDate: + # valid or invalid QDate + return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy") + + def get_pydate(self) -> datetime.date: + # valid or invalid QDate + date = self.get_date() + py_date = cast(datetime.date, date.toPython()) + + return py_date + + def reset(self) -> None: + self.calendar.hide() + self.line_edit.clear() + + +class FlexibleDateTimeInput(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._read_only = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Simple text field allows for easy editing and deletion (with Time) + self.line_edit = QLineEdit() + self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") + self.line_edit.setInputMask("99.99.9999 99:99;_") # force datetime structure + + self.button = QPushButton("📅") + self.button.setFixedWidth(30) + + layout.addWidget(self.line_edit) + layout.addWidget(self.button) + + self.calendar = QCalendarWidget() + self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window + self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + self.calendar.setGridVisible(True) + self.calendar.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + + self.button.clicked.connect(self.show_calendar) + self.calendar.activated.connect(self.set_date_from_calendar) + + # signal to validate + self.line_edit.editingFinished.connect(self.validate_input) + self.line_edit.installEventFilter(self) + + self.setReadOnly(False) + + def eventFilter( + self, + watched: QObject, + event: QEvent, + ): + # press enter: direct validation + if watched == self.line_edit: + if event.type() == QEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore + self.validate_input() + return True # mark event as processed + elif event.type() == QEvent.Type.FocusOut: + self.validate_input() + # no return to not block focus change + return super().eventFilter(watched, event) + + def show_calendar(self) -> None: + if self._read_only: + return + pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft()) + self.calendar.move(pos) + self.calendar.show() + + def isReadOnly(self) -> bool: + return self._read_only + + def setReadOnly( + self, + read_only: bool, + ): + self._read_only = read_only + self.line_edit.setReadOnly(read_only) + self.button.setEnabled(not read_only) + + current_text = self.line_edit.text() + + if read_only: + self.line_edit.setInputMask("") + self.line_edit.setPlaceholderText("kein Zeitstempel eingetragen") + pure = ( + current_text.replace(".", "") + .replace(":", "") + .replace(" ", "") + .replace("_", "") + .strip() + ) + cleaned_datetime = current_text if pure else "" + self.line_edit.setText(cleaned_datetime) + self.line_edit.setProperty("styleClass", "stempel") + else: + self.line_edit.setInputMask("99.99.9999 99:99;_") + self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") + + def validate_input(self) -> None: + logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation") + if self._read_only: + return + + focused_widget = QApplication.focusWidget() + if focused_widget == self.button or focused_widget == self.calendar: + return + + text = self.line_edit.text() + # field empty (ignoring dots, colons, spaces, underscores) + pure_text = ( + text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip() + ) + if not pure_text: + self.line_edit.setStyleSheet("") + return + + # check logic for QDateTime + dt = QDateTime.fromString(text, "dd.MM.yyyy hh:mm") + + if not dt.isValid(): + self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;") + self.line_edit.selectAll() + else: + self.line_edit.setStyleSheet("") + + def set_date_from_calendar( + self, + date: QDate, + ) -> None: + if self._read_only: + return + + # save time user already provided it + current_text = self.line_edit.text() + time_part = "00:00" + + # extract time portion (last 5 characters of "dd.MM.yyyy hh:mm") + if len(current_text) >= 16: + potential_time = current_text[11:16] + if "_" not in potential_time: # only if time was fully provided + time_part = potential_time + + if date and date.isValid(): + formatted_date = date.toString("dd.MM.yyyy") + self.line_edit.setText(f"{formatted_date} {time_part}") + self.line_edit.setStyleSheet("") + else: + self.line_edit.clear() + + self.calendar.hide() + + def set_datetime( + self, + dt: QDateTime, + ) -> None: + if dt and dt.isValid(): + self.line_edit.setText(dt.toString("dd.MM.yyyy hh:mm")) + self.line_edit.setStyleSheet("") + else: + self.line_edit.clear() + + def set_pydatetime( + self, + dt: datetime.datetime, + raise_error: bool = False, + ) -> None: + dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0) + if raise_error and not dt_to_set.isValid(): + raise ValueError("Could not parse datetime object correctly") + + self.set_datetime(dt_to_set) + + def get_datetime(self) -> QDateTime: + # valid or invalid QDateTime + return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm") + + def get_pydatetime(self) -> datetime.datetime: + """returns current value of the field as UTC datetime + + Returns + ------- + datetime.datetime + UTC datetime object + """ + # valid or invalid datetime + dt = self.get_datetime() + py_dt = cast(datetime.datetime, dt.toPython()) + py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST) + + return py_dt.astimezone(datetime.UTC) + + def reset(self) -> None: + self.calendar.hide() + self.line_edit.clear() + + class DynamicListWidget(QWidget): """ a widget, which can generate and manage an arbitrary number of sub forms. @@ -3206,7 +3651,13 @@ class Page_Consulting_ConsultingSession(QWidget): self.user_field = QLineEdit( placeholderText="wird über aktiven Nutzer bei Anlage gesetzt" ) - self.timestamp = QLineEdit() # TODO change to custom date field + self.user_field.setReadOnly(True) + self.user_field.setProperty("styleClass", "stempel") + # ?? date or datetime? + # self.timestamp = FlexibleDateInput() + # self.timestamp.set_pydate(datetime.datetime.now()) + self.timestamp = FlexibleDateTimeInput() + self.timestamp.set_pydatetime(datetime.datetime.now()) self.contact = QLineEdit(placeholderText="Freitext") self.contact_type = QLineEdit(placeholderText="sollte Dropdown sein") # Dropdown @@ -3260,7 +3711,7 @@ class Page_Consulting_ConsultingSession(QWidget): "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) set_widget_value(self.user_field, self.STATE.user_name) - set_widget_value(self.timestamp, self.STATE.timestamp) + set_widget_value(self.timestamp, self.STATE.timestamp.astimezone(TIMEZONE_CEST)) set_widget_value(self.contact, self.STATE.partner) set_widget_value(self.contact_type, self.STATE.interaction_scheme) set_widget_value(self.crm_link, self.STATE.topic) @@ -3269,9 +3720,7 @@ class Page_Consulting_ConsultingSession(QWidget): def _sync_gui_to_state(self) -> None: # TODO: get timestamp from custom field self.STATE.timestamp - self.STATE.timestamp = datetime.datetime.strptime( - self.timestamp.text(), "%d.%m.%Y %H:%M:%S" - ) + self.STATE.timestamp = self.timestamp.get_pydatetime() self.STATE.partner = self.contact.text() self.STATE.interaction_scheme = self.contact_type.text() self.STATE.topic = self.crm_link.text() # TODO: change to correct link -- 2.34.1 From deeb038956b7a2984051034614b82f44f953c58a Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 3 Jul 2026 11:59:44 +0200 Subject: [PATCH 11/74] more generic build function for widgets defined by form fields --- src/wce_crm/gui.py | 522 +++++++++++++++++++++------------------------ 1 file changed, 244 insertions(+), 278 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 97f168b..a83d396 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -35,6 +35,7 @@ from PySide6.QtGui import QAction, QFont from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QBoxLayout, QButtonGroup, QCalendarWidget, QComboBox, @@ -272,19 +273,244 @@ class CustomWidget(QWidget): def validate_form_data(self) -> list[str]: ... +def _add_widget_to_layout( + form_field: FormField, + widget: QWidget, + target_layout: QLayout, + widget_only: bool = False, +) -> None: + + if isinstance(target_layout, QFormLayout): + if widget_only: + target_layout.addRow(widget) + elif form_field.tooltip: + tooltip_layout = _add_tooltip(widget, form_field.tooltip) + target_layout.addRow(form_field.label, tooltip_layout) + else: + target_layout.addRow(form_field.label, widget) + elif isinstance(target_layout, QBoxLayout): + if widget_only: + target_layout.addWidget(widget) + elif form_field.tooltip: + tooltip_layout = _add_tooltip(widget, form_field.tooltip) + target_layout.addLayout(tooltip_layout) + else: + target_layout.addWidget(widget) + + +def build_and_add_widget( + form_field: FormField, + parent_layout: QLayout, + prefix: str = "", +) -> tuple[str, QWidget]: + no_scroll_filter = NoScrollFilter(parent_layout) + if prefix and form_field.key: + full_key = f"{prefix}{COLUMN_SEP}{form_field.key}" if prefix else form_field.key + elif prefix and not form_field.key: + full_key = f"{prefix}" + else: + full_key = form_field.key + + widget: QWidget + layout_widget_only: bool = False + install_no_scroll_filter: bool = True + + match form_field.type: + case ( + FormFieldType.DYNAMIC_LIST + | FormFieldType.DYNAMIC_DROPDOWN_NUMERIC + | FormFieldType.DYNAMIC_DROPDOWN_OPTION + | FormFieldType.CUSTOM + ): + layout_widget_only = True + install_no_scroll_filter = False + + match form_field.type: + case FormFieldType.GROUP: + raise TypeError("FormField Groups are not supported for direct building") + + case FormFieldType.TEXT | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME: + widget = QLineEdit() + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setText(form_field.fill_value) + if form_field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.TEXT_SEARCH: + widget = QLineEdit() + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + else: + widget.setPlaceholderText("Tippen zum Suchen...") + + search_completer = QCompleter() + search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + search_completer.setFilterMode(Qt.MatchFlag.MatchContains) + widget.setCompleter(search_completer) + + case FormFieldType.LONGTEXT: + widget = QPlainTextEdit() + widget.setMaximumHeight(80) + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.DATE: + widget = QDateEdit() + widget.setCalendarPopup(True) + cal = widget.calendarWidget() + if cal: + cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + cal.setGridVisible(True) + cal.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.setDate(set_date) + else: + widget.setDate(QDate.currentDate()) + + if form_field.readonly: + widget = QLineEdit() + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + if form_field.fill_value: + widget.setText(form_field.fill_value) + + case FormFieldType.DATE_FLEXIBLE: + widget = FlexibleDateInput() + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_date(set_date) + + if form_field.readonly: + widget.setReadOnly(True) + + case FormFieldType.DATETIME_FLEXIBLE: + widget = FlexibleDateTimeInput() + if form_field.fill_value: + set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm") + if not set_datetime.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_datetime(set_datetime) + + if form_field.readonly: + widget.setReadOnly(True) + + case FormFieldType.DROPDOWN: + widget = QComboBox() + widget.addItem(DROPDOWN_DEFAULT, None) + for option in form_field.dropdown_options: + widget.addItem(option.label, option.data) + + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setCurrentText(form_field.fill_value) + else: + widget.setCurrentIndex(0) + if form_field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.EXTENDED_DROPDOWN: + widget = QComboBox() + widget.setEditable(True) + widget.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + completer = widget.completer() + assert completer + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + completer.setFilterMode(Qt.MatchFlag.MatchContains) + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + + widget.addItem(DROPDOWN_DEFAULT, None) + for option in form_field.dropdown_options: + widget.addItem(option.label, option.data) + + if form_field.placeholder: + line_edit = widget.lineEdit() + assert line_edit + line_edit.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setCurrentText(form_field.fill_value) + else: + widget.setCurrentIndex(-1) + if form_field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.DYNAMIC_LIST: + widget = DynamicListWidget( + form_field.children, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.DYNAMIC_DROPDOWN_NUMERIC: + widget = DynamicDropdownWidgetNumeric( + form_field.children, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.DYNAMIC_DROPDOWN_OPTION: + widget = DynamicDropdownWidgetOption( + form_field.children, + form_field.trigger_value, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.CUSTOM: + widget_class = CUSTOM_WIDGETS[form_field.custom_widget] + widget = widget_class( + form_fields=form_field.children, + label=form_field.label, + prefix=f"{full_key}", + ) + + case _: + raise NotImplementedError(f"Not supported field type: {form_field.type.value}") + + if install_no_scroll_filter: + widget.installEventFilter(no_scroll_filter) + + _add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only) + + return full_key, widget + + def _build_ui_recursively( schema: Sequence[FormField], parent_layout: QFormLayout, widget_registry: WidgetRegistry, prefix: str = "", ) -> list[str]: - no_scroll_filter = NoScrollFilter(parent_layout) keys: list[str] = [] for field in schema: if prefix and field.key: full_key = f"{prefix}{COLUMN_SEP}{field.key}" if prefix else field.key elif prefix and not field.key: - full_key = f"{prefix}" + full_key = prefix else: full_key = field.key @@ -302,283 +528,13 @@ def _build_ui_recursively( ) parent_layout.addRow(group_box) - case FormFieldType.TEXT | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME: - widget = QLineEdit() - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setText(field.fill_value) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.TEXT_SEARCH: - widget = QLineEdit() - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - else: - widget.setPlaceholderText("Tippen zum Suchen...") - - search_completer = QCompleter() - search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - search_completer.setFilterMode(Qt.MatchFlag.MatchContains) - widget.setCompleter(search_completer) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.LONGTEXT: - widget = QPlainTextEdit() - widget.setMaximumHeight(80) - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DATE: - widget = QDateEdit() - widget.setCalendarPopup(True) - cal = widget.calendarWidget() - if cal: - cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday) - cal.setGridVisible(True) - cal.setStyleSheet(""" - QCalendarWidget QAbstractItemView { - selection-background-color: #2980b9; - selection-color: white; - } - """) - if field.fill_value: - set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") - if not set_date.isValid(): - raise ValueError( - f"Could not parse date field value >{field.fill_value}<" - ) - widget.setDate(set_date) - else: - widget.setDate(QDate.currentDate()) - - if field.readonly: - widget = QLineEdit() - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - if field.fill_value: - widget.setText(field.fill_value) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DATE_FLEXIBLE: - widget = FlexibleDateInput() - if field.fill_value: - set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") - if not set_date.isValid(): - raise ValueError( - f"Could not parse date field value >{field.fill_value}<" - ) - widget.set_date(set_date) - - if field.readonly: - widget.setReadOnly(True) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DATETIME_FLEXIBLE: - widget = FlexibleDateTimeInput() - if field.fill_value: - set_datetime = QDateTime.fromString(field.fill_value, "dd.MM.yyyy hh:mm") - if not set_datetime.isValid(): - raise ValueError( - f"Could not parse date field value >{field.fill_value}<" - ) - widget.set_datetime(set_datetime) - - if field.readonly: - widget.setReadOnly(True) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DROPDOWN: - widget = QComboBox() - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(0) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.EXTENDED_DROPDOWN: - widget = QComboBox() - widget.setEditable(True) - widget.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - completer = widget.completer() - assert completer - completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - completer.setFilterMode(Qt.MatchFlag.MatchContains) - completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - line_edit = widget.lineEdit() - assert line_edit - line_edit.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(-1) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DYNAMIC_LIST: - widget = DynamicListWidget( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.DYNAMIC_DROPDOWN_NUMERIC: - widget = DynamicDropdownWidgetNumeric( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.DYNAMIC_DROPDOWN_OPTION: - widget = DynamicDropdownWidgetOption( - field.children, - field.trigger_value, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.CUSTOM: - widget_class = CUSTOM_WIDGETS[field.custom_widget] - widget = widget_class( - form_fields=field.children, - label=field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - case _: - raise NotImplementedError(f"Not supported field type: {field.type.value}") - - keys.append(full_key) + full_key, widget = build_and_add_widget(field, parent_layout, prefix=prefix) + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + keys.append(full_key) return keys @@ -1589,6 +1545,11 @@ class FlexibleDateInput(QWidget): self.button = QPushButton("📅") self.button.setFixedWidth(30) + self.line_edit.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + layout.addWidget(self.line_edit) layout.addWidget(self.button) @@ -1748,6 +1709,11 @@ class FlexibleDateTimeInput(QWidget): self.button = QPushButton("📅") self.button.setFixedWidth(30) + self.line_edit.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + layout.addWidget(self.line_edit) layout.addWidget(self.button) -- 2.34.1 From afa8fb351d6eaa69642dd8122fc356aba9fa76c5 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 3 Jul 2026 13:00:56 +0200 Subject: [PATCH 12/74] generic typing of field definitions and value getter function for single widgets --- src/wce_crm/form_defs.py | 9 +- src/wce_crm/gui.py | 217 +++++++++++++++++++++++++-------------- 2 files changed, 149 insertions(+), 77 deletions(-) diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index c58f359..c50b8d8 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -4,12 +4,17 @@ import dataclasses as dc import enum import uuid from collections.abc import Sequence -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar import babel from wce_crm.custom_widget_registry import CUSTOM_WIDGET_NAMES +if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + +T = TypeVar("T", bound="QWidget") + class FormFieldType(enum.StrEnum): GROUP = enum.auto() @@ -31,7 +36,7 @@ class FormFieldType(enum.StrEnum): @dc.dataclass(slots=True) -class FormField: +class FormField(Generic[T]): label: str type: FormFieldType children: Sequence[FormField] = dc.field(default_factory=list) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index a83d396..2b44401 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -12,7 +12,17 @@ from collections import defaultdict from collections.abc import Container, Iterable, Sequence from pathlib import Path from pprint import pformat -from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + Protocol, + TypeAlias, + TypedDict, + TypeVar, + cast, +) from typing_extensions import override from dopt_basics import datetime as dopt_dt @@ -92,6 +102,7 @@ if TYPE_CHECKING: K = TypeVar("K") V = TypeVar("V") +T = TypeVar("T", bound="QWidget") DEBUG: bool = True @@ -274,7 +285,7 @@ class CustomWidget(QWidget): def _add_widget_to_layout( - form_field: FormField, + form_field: FormField[T], widget: QWidget, target_layout: QLayout, widget_only: bool = False, @@ -299,10 +310,10 @@ def _add_widget_to_layout( def build_and_add_widget( - form_field: FormField, + form_field: FormField[T], parent_layout: QLayout, prefix: str = "", -) -> tuple[str, QWidget]: +) -> tuple[str, T]: no_scroll_filter = NoScrollFilter(parent_layout) if prefix and form_field.key: full_key = f"{prefix}{COLUMN_SEP}{form_field.key}" if prefix else form_field.key @@ -311,7 +322,6 @@ def build_and_add_widget( else: full_key = form_field.key - widget: QWidget layout_widget_only: bool = False install_no_scroll_filter: bool = True @@ -496,7 +506,7 @@ def build_and_add_widget( _add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only) - return full_key, widget + return full_key, cast(T, widget) def _build_ui_recursively( @@ -711,40 +721,35 @@ def get_form_data( ignored_keys.append(key) continue - if isinstance(widget, QLineEdit): - data = widget.text() - if data != "": - value = data - if form_field.type is FormFieldType.TEXT_DATE: - value = datetime.datetime.strptime(value, DATE_FMT).date() - elif form_field.type is FormFieldType.TEXT_DATETIME: - value = datetime.datetime.strptime(value, DATETIME_FMT) - elif isinstance(widget, QPlainTextEdit): - data = widget.toPlainText() - if data != "": - value = data - elif isinstance(widget, QDateEdit): - qt_date = widget.date() - value = qt_date.toPython() - elif isinstance(widget, FlexibleDateInput): - value = widget.get_pydate() - elif isinstance(widget, FlexibleDateTimeInput): - value = widget.get_pydatetime() - elif isinstance(widget, QComboBox): - value = widget.currentData() - elif isinstance( - widget, - ( - DynamicListWidget, - DynamicDropdownWidgetNumeric, - DynamicDropdownWidgetOption, - CustomWidget, - ), - ): - # this is a special data structure with some assumptions of the widget's internals - # DynamicListWidget: this is a list -> each dynamic list contains a list - # of such dictionaries (handled below) - value = widget.get_form_data() + if form_field.type is FormFieldType.TEXT_DATE: + value = get_widget_value(widget, "date") + elif form_field.type is FormFieldType.TEXT_DATETIME: + value = get_widget_value(widget, "datetime") + else: + value = get_widget_value(widget) + + # elif isinstance(widget, QDateEdit): + # qt_date = widget.date() + # value = qt_date.toPython() + # elif isinstance(widget, FlexibleDateInput): + # value = widget.get_pydate() + # elif isinstance(widget, FlexibleDateTimeInput): + # value = widget.get_pydatetime() + # elif isinstance(widget, QComboBox): + # value = widget.currentData() + # elif isinstance( + # widget, + # ( + # DynamicListWidget, + # DynamicDropdownWidgetNumeric, + # DynamicDropdownWidgetOption, + # CustomWidget, + # ), + # ): + # # this is a special data structure with some assumptions of the widget's internals + # # DynamicListWidget: this is a list -> each dynamic list contains a list + # # of such dictionaries (handled below) + # value = widget.get_form_data() if isinstance(value, dict): raw_data.update(value) @@ -756,6 +761,50 @@ def get_form_data( return raw_data +def get_widget_value( + widget: QWidget, + text_type: Literal["default", "date", "datetime"] = "default", +) -> Any: + value: Any | None = None + + if isinstance(widget, QLineEdit): + data = widget.text() + if data != "": + value = data + if text_type == "date": + value = datetime.datetime.strptime(value, DATE_FMT).date() + elif text_type == "datetime": + value = datetime.datetime.strptime(value, DATETIME_FMT) + elif isinstance(widget, QPlainTextEdit): + data = widget.toPlainText() + if data != "": + value = data + elif isinstance(widget, QDateEdit): + qt_date = widget.date() + value = qt_date.toPython() + elif isinstance(widget, FlexibleDateInput): + value = widget.get_pydate() + elif isinstance(widget, FlexibleDateTimeInput): + value = widget.get_pydatetime() + elif isinstance(widget, QComboBox): + value = widget.currentData() + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomWidget, + ), + ): + # this is a special data structure with some assumptions of the widget's internals + # DynamicListWidget: this is a list -> each dynamic list contains a list + # of such dictionaries (handled below) + value = widget.get_form_data() + + return value + + def set_widget_value( widget: QWidget, value: Any, @@ -3301,17 +3350,19 @@ class Session: @dc.dataclass(slots=True) class Page_Consulting_ConsultingSession_State: - user_id: UserId # must be set with the session (always known) - user_name: str # must be set with the session (always known) - cons_session_id: int | None = None # known and not known entries - timestamp: datetime.datetime = dc.field( + nutzer_id: UserId # must be set with the session (always known) + nutzer_name: str # must be set with the session (always known) + beratung_id: int | None = None # known and not known entries + zeitstempel: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) ) - partner: str = "" - interaction_scheme: str = "" # set string like in other dropdowns or use integer (enum) - topic: str = "" # TODO should be linked to something, target unclear (need specification by customer) - notes: str = "" - response: str = "" + ansprechpartner: str | None = None + interaction_scheme: str | None = None + topic: str | None = ( + None # TODO should be linked to something, target unclear (need specification by customer) + ) + anmerkungen: str | None = None + rueckmeldung: str | None = None @dc.dataclass(slots=True) @@ -3614,23 +3665,38 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) ROW_HEIGHT = 70 - self.user_field = QLineEdit( - placeholderText="wird über aktiven Nutzer bei Anlage gesetzt" + user_field_def = FormField[QLineEdit]( + "", + FormFieldType.TEXT, + readonly=True, + placeholder="wird über aktiven Nutzer bei Anlage gesetzt", ) - self.user_field.setReadOnly(True) - self.user_field.setProperty("styleClass", "stempel") + _, self.user_field = build_and_add_widget(user_field_def, layout) + # ?? date or datetime? - # self.timestamp = FlexibleDateInput() - # self.timestamp.set_pydate(datetime.datetime.now()) - self.timestamp = FlexibleDateTimeInput() + timestamp_def = FormField[FlexibleDateTimeInput]("", FormFieldType.DATETIME_FLEXIBLE) + _, self.timestamp = build_and_add_widget(timestamp_def, layout) self.timestamp.set_pydatetime(datetime.datetime.now()) - self.contact = QLineEdit(placeholderText="Freitext") - self.contact_type = QLineEdit(placeholderText="sollte Dropdown sein") # Dropdown - self.crm_link = QLineEdit(placeholderText="CRM Links") + contact_def = FormField[QLineEdit]("", FormFieldType.TEXT, placeholder="Freitext") + _, self.contact = build_and_add_widget(contact_def, layout) - self.plain_text = QPlainTextEdit() - self.response = QPlainTextEdit() + contact_type_def = FormField[QComboBox]( + "", + FormFieldType.DROPDOWN, + placeholder="sollte Dropdown sein", + options=[("Persönlich", None), ("Telefon", None), ("Mail", None)], + ) + _, self.contact_type = build_and_add_widget(contact_type_def, layout) + + crm_link_def = FormField[QLineEdit]("", FormFieldType.TEXT, placeholder="CRM Links") + _, self.crm_link = build_and_add_widget(crm_link_def, layout) + + plain_text_def = FormField[QPlainTextEdit]("", FormFieldType.LONGTEXT) + _, self.plain_text = build_and_add_widget(plain_text_def, layout) + + response_def = FormField[QPlainTextEdit]("", FormFieldType.LONGTEXT) + _, self.response = build_and_add_widget(response_def, layout) self.btn_delete = QPushButton("✕") self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen @@ -3639,6 +3705,7 @@ class Page_Consulting_ConsultingSession(QWidget): self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;") self.btn_delete.clicked.connect(lambda: self.delete_request.emit(self)) self.btn_delete.setFixedWidth(30) + layout.addWidget(self.btn_delete) widgets = ( self.user_field, @@ -3652,7 +3719,7 @@ class Page_Consulting_ConsultingSession(QWidget): ) for widget in widgets: widget.setFixedHeight(ROW_HEIGHT) - layout.addWidget(widget) + # layout.addWidget(widget) # column stretching: must be matched with header num_widgets = len(widgets) @@ -3676,22 +3743,22 @@ class Page_Consulting_ConsultingSession(QWidget): logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) - set_widget_value(self.user_field, self.STATE.user_name) - set_widget_value(self.timestamp, self.STATE.timestamp.astimezone(TIMEZONE_CEST)) - set_widget_value(self.contact, self.STATE.partner) + set_widget_value(self.user_field, self.STATE.nutzer_name) + set_widget_value(self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)) + set_widget_value(self.contact, self.STATE.ansprechpartner) set_widget_value(self.contact_type, self.STATE.interaction_scheme) set_widget_value(self.crm_link, self.STATE.topic) - set_widget_value(self.plain_text, self.STATE.notes) - set_widget_value(self.response, self.STATE.response) + set_widget_value(self.plain_text, self.STATE.anmerkungen) + set_widget_value(self.response, self.STATE.rueckmeldung) def _sync_gui_to_state(self) -> None: # TODO: get timestamp from custom field self.STATE.timestamp - self.STATE.timestamp = self.timestamp.get_pydatetime() - self.STATE.partner = self.contact.text() - self.STATE.interaction_scheme = self.contact_type.text() - self.STATE.topic = self.crm_link.text() # TODO: change to correct link - self.STATE.notes = self.plain_text.toPlainText() - self.STATE.response = self.response.toPlainText() + self.STATE.zeitstempel = self.timestamp.get_pydatetime() + self.STATE.ansprechpartner = get_widget_value(self.contact) + self.STATE.interaction_scheme = get_widget_value(self.contact_type) + self.STATE.topic = get_widget_value(self.crm_link) # TODO: change to correct link + self.STATE.anmerkungen = get_widget_value(self.plain_text) + self.STATE.rueckmeldung = get_widget_value(self.response) def get_data(self) -> Page_Consulting_ConsultingSession_State: self._sync_gui_to_state() @@ -3873,8 +3940,8 @@ class Page_Consulting_Table(QWidget): ) -> None: if row_state is None: row_state = Page_Consulting_ConsultingSession_State( - user_id=self.SITE_STATE.session.user_id, - user_name=self.SITE_STATE.session.user_name, + nutzer_id=self.SITE_STATE.session.user_id, + nutzer_name=self.SITE_STATE.session.user_name, ) row = Page_Consulting_ConsultingSession(site_state=self.SITE_STATE, state=row_state) -- 2.34.1 From 7c26c1fc2e2235e090fabc08b7312dd6daacb850 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 3 Jul 2026 13:01:15 +0200 Subject: [PATCH 13/74] update DB schema for consulting session --- data_model/project_db_schema.aml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 16e697b..0fe031e 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -93,6 +93,12 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden beratung_id int pk {autoIncrement} vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. - erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable - aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} - weitere_merkmale_ausstehend text nullable # Merkmale noch zu ergänzen + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + nutzer_id int | externe Nutzerverwaltung + nutzer_name text | externe Nuttzerverwaltung + ansprechpartner text nullable + thema text nullable | Verlinkung ins CRM, genaue Gestaltung noch unklar + anmerkungen text nullable + rueckmeldung txt nullable + -- 2.34.1 From 62e69184d723a7eadf484650508616bca0e8889a Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 6 Jul 2026 15:57:21 +0200 Subject: [PATCH 14/74] major refactor with new state mechanics --- prototypes/tests.py | 2 +- src/wce_crm/backend/backend.py | 176 ++++---- src/wce_crm/gui.py | 753 +++++++++++++++++++++------------ src/wce_crm/types.py | 75 +++- 4 files changed, 636 insertions(+), 370 deletions(-) diff --git a/prototypes/tests.py b/prototypes/tests.py index 1b5102f..4e61039 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -44,7 +44,7 @@ for r in res: # %% -backend.front_get_company_list() +backend._main_page_get_company_list() # %% diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index d729aac..d382a6b 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -1,70 +1,18 @@ from __future__ import annotations -import dataclasses as dc import datetime -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, cast import polars as pl import sqlalchemy as sql from wce_crm import db +from wce_crm.constants import TIMEZONE_CEST from wce_crm.logging import logger_back as logger +from wce_crm.types import CompanyInfo, ContactPersonInfo, InitRecType, MainPageEntry if TYPE_CHECKING: - from wce_crm.types import ExtAnId, ExtMaId, InitRecId - - -# // externals -class CompanyInfo(TypedDict): - ma_id: str - wce_id: str - ma_unternehmensname: str - ma_branche: str - ma_strasse: str - ma_hausnummer: str - ma_plz: str - ma_ort: str - ma_plz_postfach: str - ma_postfach: str - ma_website: str - ma_mail: str - ma_telefonnummer: str - ma_faxnummer: str - ma_ersteintrag_datum: str - ma_aktualisierung_datum: str - ma_aktualisierung_nutzer: str - ma_sollprozess: str - ma_auslaendische_mitarbeiter: str - ma_quelle_information: str - ma_bemerkung: str - ma_kontakt: str - ma_schlagworte: str - ma_archiviert: str - - -class ContactPersonInfo(TypedDict): - an_id: str - ma_id: str - wce_id: str - st_id: str - an_sachgebiet: str - an_anrede: str - an_titel: str - an_nachname: str - an_vorname: str - an_position: str - an_mail: str - an_festnetz: str - an_mobil: str - an_faxnummer: str - an_hauptansprechpartner: str - an_anrede_anschrift: str - an_bemerkung: str - an_aktualisierung_datum: str - an_aktualisierung_nutzer: str - an_letztes_kontaktdatum: str - an_ersteintrag_datum: str - an_archiviert: str + from wce_crm.types import ExtAnId, ExtMaId, RecId def _transform_for_gui_output( @@ -156,7 +104,7 @@ def initrec_comp_contact_person_search_get_info( # // internals def initrec_company_insert_initial_recording( data: dict[str, Any], -) -> InitRecId: +) -> RecId: logger.debug("[Call backend] insert_initial_recording") stmt = db.grunderfassung_unternehmen.insert() with db.ENGINE.begin() as conn: @@ -172,7 +120,7 @@ def initrec_company_insert_initial_recording( def initrec_company_update_initial_recording( - id_: InitRecId, + id_: RecId, data: dict[str, Any], ) -> None: logger.debug("[Call backend] update_initial_recording") @@ -184,7 +132,7 @@ def initrec_company_update_initial_recording( def initrec_company_get_initial_recording( - id_: InitRecId, + id_: RecId, ) -> dict[str, Any]: logger.debug("[Call backend] get_initial_recording") stmt = db.grunderfassung_unternehmen.select().where( @@ -203,7 +151,7 @@ def initrec_company_get_initial_recording( def initrec_company_delete_initial_recording( - id_: InitRecId, + id_: RecId, ) -> None: logger.debug("[Call backend] delete_initial_recording") stmt = db.grunderfassung_unternehmen.delete().where( @@ -218,7 +166,7 @@ def initrec_company_delete_initial_recording( def initrec_person_insert_initial_recording( data: dict[str, Any], -) -> InitRecId: +) -> RecId: logger.debug("[Call backend] insert_initial_recording") stmt = db.grunderfassung_personen.insert() with db.ENGINE.begin() as conn: @@ -234,7 +182,7 @@ def initrec_person_insert_initial_recording( def initrec_person_update_initial_recording( - id_: InitRecId, + id_: RecId, data: dict[str, Any], ) -> None: logger.debug("[Call backend] update_initial_recording") @@ -246,7 +194,7 @@ def initrec_person_update_initial_recording( def initrec_person_get_initial_recording( - id_: InitRecId, + id_: RecId, ) -> dict[str, Any]: logger.debug("[Call backend] get_initial_recording") stmt = db.grunderfassung_personen.select().where( @@ -265,7 +213,7 @@ def initrec_person_get_initial_recording( def initrec_person_delete_initial_recording( - id_: InitRecId, + id_: RecId, ) -> None: logger.debug("[Call backend] delete_initial_recording") stmt = db.grunderfassung_personen.delete().where( @@ -278,56 +226,86 @@ def initrec_person_delete_initial_recording( raise KeyError(f"Database ID {id_} not found for deletion") -@dc.dataclass(slots=True) -class FrontpageCompany: - erfassung_id: InitRecId - name: str - Metadaten_aktualisierung: datetime.datetime - is_company: bool - - -# TODO separate functions for companies and person: focus on companies first -def front_get_company_list() -> list[FrontpageCompany]: +# // main page interaction +def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") stmt = sql.select( db.grunderfassung_unternehmen.c.un_id, db.grunderfassung_unternehmen.c.Partnersuche__un_suche, - # db.grunderfassung_unternehmen.c.Stammdaten__vorname, - # db.grunderfassung_unternehmen.c.Stammdaten__name, db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - ).order_by(db.grunderfassung_unternehmen.c.Metadaten_aktualisierung.desc()) + ) with db.ENGINE.connect() as conn: res = conn.execute(stmt) - front_page_companies: list[FrontpageCompany] = [] + main_page_companies: list[MainPageEntry] = [] for entry in res.mappings(): - erfassung_id = entry["un_id"] - ma_id = entry["Partnersuche__un_suche"] + rec_id = entry["un_id"] + assert rec_id, "no RecID defined" + ma_id_external = entry["Partnersuche__un_suche"] + assert ma_id_external is not None, "external MA ID is NULL" datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) - datetime_akt = datetime_akt.astimezone() + datetime_akt = datetime_akt.astimezone(TIMEZONE_CEST) - name: str - is_company: bool - if ma_id is not None: - comp_info = initrec_comp_search_get_info(ma_id) - name = comp_info["ma_unternehmensname"] - is_company = True - else: - surname: str = entry["Stammdaten__name"] - first_name: str = entry["Stammdaten__vorname"] - names_to_join = [n for n in (first_name, surname) if n] - name = " ".join(names_to_join) - is_company = False + comp_info = initrec_comp_search_get_info(ma_id_external) + display_name = comp_info["ma_unternehmensname"] - front_page_companies.append( - FrontpageCompany( - erfassung_id=erfassung_id, - name=name, + main_page_companies.append( + MainPageEntry( + rec_id=rec_id, + display_name=display_name, Metadaten_aktualisierung=datetime_akt, - is_company=is_company, + type=InitRecType.COMPANY, ) ) - return front_page_companies + return main_page_companies + + +def _main_page_get_person_list() -> list[MainPageEntry]: + logger.debug("[Call backend] get_company_list") + stmt = sql.select( + db.grunderfassung_personen.c.pers_id, + db.grunderfassung_personen.c.Stammdaten__vorname, + db.grunderfassung_personen.c.Stammdaten__name, + db.grunderfassung_personen.c.Metadaten_aktualisierung, + ) + + with db.ENGINE.connect() as conn: + res = conn.execute(stmt) + + main_page_persons: list[MainPageEntry] = [] + + for entry in res.mappings(): + rec_id = entry["pers_id"] + assert rec_id, "no RecID defined" + datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) + datetime_akt = datetime_akt.astimezone() + + surname: str = entry["Stammdaten__name"] + first_name: str = entry["Stammdaten__vorname"] + names_to_join = [n for n in (first_name, surname) if n] + display_name = " ".join(names_to_join) + + main_page_persons.append( + MainPageEntry( + rec_id=rec_id, + display_name=display_name, + Metadaten_aktualisierung=datetime_akt, + type=InitRecType.PERSON, + ) + ) + + return main_page_persons + + +def main_page_get_entries() -> list[MainPageEntry]: + companies = _main_page_get_company_list() + persons = _main_page_get_person_list() + + all_entries = companies + persons + + all_entries.sort(key=lambda x: x.Metadaten_aktualisierung, reverse=True) + + return all_entries diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 2b44401..8f02800 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -75,6 +75,7 @@ from PySide6.QtWidgets import ( ) import wce_crm.constants +from wce_crm import types from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( @@ -95,7 +96,7 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) -from wce_crm.types import ExtMaId, InitRecId, UserId +from wce_crm.types import ExtMaId, RecId, UserId if TYPE_CHECKING: ... @@ -222,11 +223,25 @@ class CustomForm(Protocol): def validate_form_data(self) -> list[str]: ... +class Module(Protocol): + def sync_state_to_GUI(self) -> None: ... + + def sync_GUI_to_state(self) -> None: ... + + def validate(self) -> None: ... + + def load_state(self, new_state: Any) -> None: ... + + def get_state(self) -> Any: ... + + def save_data(self) -> None: ... + + class AutoFormInsert(Protocol): def __call__( self, data: dict[str, Any], - ) -> backend.InitRecId: ... + ) -> backend.RecId: ... class AutoFormUpdate(Protocol): @@ -263,6 +278,14 @@ class AutoFormConfig: add_buttons: bool = True +@dc.dataclass(slots=True) +class AutoForm_State: + session: Session + cfg: AutoFormConfig + rec_id: RecId | None = None + form_data: dict[str, Any] | None = None + + class CustomWidget(QWidget): def __init__( self, @@ -284,6 +307,74 @@ class CustomWidget(QWidget): def validate_form_data(self) -> list[str]: ... +@dc.dataclass(slots=True) +class Session: + user_id: UserId + user_name: str + + +@dc.dataclass(slots=True) +class Page_NewInitRec_State: + session: Session + + +@dc.dataclass(slots=True) +class Page_InitRecCompany_State: + session: Session + un_id: RecId | None = None + + +@dc.dataclass(slots=True) +class Page_InitRecPerson_State: + session: Session + pers_id: RecId | None = None + + +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIUAL = enum.auto() + + +@dc.dataclass(slots=True) +class Page_Consulting_State: + session: Session + un_id: RecId | None = None + pers_id: RecId | None = None + cons_type: ConsultingType | None = None + cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( + default_factory=list + ) + + +@dc.dataclass(slots=True) +class Page_Consulting_ConsultingSession_State: + nutzer_id: UserId # must be set with the session (always known) + nutzer_name: str # must be set with the session (always known) + beratung_id: int | None = None # known and not known entries + zeitstempel: datetime.datetime = dc.field( + default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) + ) + ansprechpartner: str | None = None + interaction_scheme: str | None = None + topic: str | None = ( + None # TODO should be linked to something, target unclear (need specification by customer) + ) + anmerkungen: str | None = None + rueckmeldung: str | None = None + + +@dc.dataclass(slots=True) +class Page_Consulting_Table_State: + rows: list[Page_Consulting_ConsultingSession] = dc.field(default_factory=list) + row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True) +class Page_CompanyProfile_State: + session: Session + rec_id: RecId | None = None + + def _add_widget_to_layout( form_field: FormField[T], widget: QWidget, @@ -944,8 +1035,8 @@ def validate_form_data( continue error_post = True elif isinstance(widget, FlexibleDateTimeInput): - date = widget.get_datetime() - if date and date.isValid(): + datetime = widget.get_datetime() + if datetime and datetime.isValid(): continue error_post = True elif isinstance( @@ -1093,7 +1184,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_company( self, - data: backend.CompanyInfo, + data: types.CompanyInfo, ) -> None: for key, widget in self.company_widgets.items(): if key not in data: @@ -1107,7 +1198,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_person( self, - data: backend.ContactPersonInfo, + data: types.ContactPersonInfo, ) -> None: for key, widget in self.person_widgets.items(): if key not in data: @@ -1255,12 +1346,11 @@ class AutoForm(QWidget): def __init__( self, - session: Session, - cfg: AutoFormConfig, + state: AutoForm_State, ) -> None: super().__init__() - self.session = session - self.cfg = cfg + self.STATE = state + self.cfg = state.cfg self.setStyleSheet(""" QGroupBox { font-size: 16px; @@ -1310,7 +1400,7 @@ class AutoForm(QWidget): self.main_layout.addWidget(self.registry_button) self.test_button = QPushButton("Initialisiere Laden") - self.test_button.clicked.connect(self.load_data) + self.test_button.clicked.connect(self._load_data) self.test_button.setFixedHeight(35) self.test_button.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed @@ -1319,11 +1409,11 @@ class AutoForm(QWidget): button_get = QPushButton("GET DATA") button_get.setFixedHeight(35) - button_get.clicked.connect(self.get_form_data) + button_get.clicked.connect(self._get_form_data) self.main_layout.addWidget(button_get) button_set = QPushButton("SET DATA") button_set.setFixedHeight(35) - button_set.clicked.connect(self.set_form_data) + button_set.clicked.connect(self._set_form_data) self.main_layout.addWidget(button_set) id_field_layout = QHBoxLayout() @@ -1385,7 +1475,7 @@ class AutoForm(QWidget): self.reset_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - self.reset_btn.clicked.connect(self.reset_form) + self.reset_btn.clicked.connect(self._reset_form) self.layout_btn.addWidget(self.reset_btn) # delete self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") @@ -1394,11 +1484,9 @@ class AutoForm(QWidget): self.delete_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - self.delete_btn.clicked.connect(self.delete_data) + self.delete_btn.clicked.connect(self._delete_data) self.layout_btn.addWidget(self.delete_btn) - self.current_id: InitRecId = -1 - logger_auto_form.debug( "Initialised Auto Form Widget. Registry:%s", pformat_registry(self.widget_registry), @@ -1411,9 +1499,9 @@ class AutoForm(QWidget): try: index = int(self.id_field_input.text()) except ValueError: - index = -1 - self.current_id = index - logger_gui.debug("Set index to %d", self.current_id) + index = None + self.STATE.rec_id = index + logger_gui.debug("Set index to %d, new state: %s", index, self.STATE) def _disable_save(self) -> None: self.save_btn.setEnabled(False) @@ -1428,18 +1516,18 @@ class AutoForm(QWidget): self.save_btn.setText(self.save_btn_txt_enabled) def _activation_delete(self) -> None: - if self.current_id > -1: + if self.STATE.rec_id is not None: self.delete_btn.setEnabled(True) else: self.delete_btn.setEnabled(False) - def delete_data(self) -> None: - assert self.current_id > -1, "deletion initialised despite no index set" - self.cfg.data_delete(self.current_id) + def _delete_data(self) -> None: + assert self.STATE.rec_id is not None, "deletion initialised despite no index set" + self.cfg.data_delete(self.STATE.rec_id) self.update_triggered.emit() - self.reset_form() + self._reset_form() - def load_data( + def _load_data( self, lookup_id: int | None = None, ) -> None: @@ -1447,17 +1535,18 @@ class AutoForm(QWidget): return logger_auto_form.info(">>>> LOAD CLICKED") - if lookup_id is None or lookup_id == 0: - lookup_id = self.current_id - self.reset_form() + if lookup_id is None: + # only debugging + lookup_id = self.STATE.rec_id + self._reset_form() logger_auto_form.debug("Lookup ID: %d", lookup_id) - if lookup_id > 0: + if lookup_id is not None and lookup_id > 0: logger_auto_form.debug("Load from DB:") loaded_data = self.cfg.data_get(lookup_id) else: - logger_auto_form.error("Loading: Lookup ID <= 0. Do nothing!") + logger_auto_form.error("Loading: Lookup ID 0 or NULL. Do nothing!") return logger_auto_form.debug( @@ -1469,13 +1558,63 @@ class AutoForm(QWidget): form_data = model.to_gui() logger_auto_form.debug("Set form data...") logger_auto_form.debug("Form data:\n%s", pformat(form_data)) - self.set_form_data(form_data) - self.current_id = lookup_id + self._set_form_data(form_data) + self.STATE.rec_id = lookup_id self._activation_delete() + def _validate_form_data(self) -> list[str]: + return validate_form_data(self.widget_registry) + + def _reset_form(self) -> None: + reset_form(self.widget_registry) + self.STATE.rec_id = None + self._activation_delete() + + def _get_form_data(self) -> dict[str, Any]: + form_data = get_form_data(self.widget_registry) + logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call get form data:") + logger_auto_form.debug("Form Data:\n%s", pformat(form_data)) + + if DEBUG_GET_SET: + self.debug_form_data = form_data + + return form_data + + def _set_form_data( + self, + data: dict[str, Any], + ) -> None: + logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call set form data:") + if DEBUG_GET_SET: + data = self.debug_form_data + + set_form_data(self.widget_registry, data) + + def sync_state_to_GUI(self) -> None: + self._load_data(self.STATE.rec_id) + + def sync_GUI_to_state(self) -> None: + form_data = self._get_form_data() + self.STATE.form_data = form_data + + def validate(self) -> None: ... + + def load_state( + self, + new_state: AutoForm_State, + ) -> None: + self.STATE.rec_id = new_state.rec_id + self.STATE.form_data = new_state.form_data + self.sync_state_to_GUI() + + def get_state(self) -> AutoForm_State: + self.sync_GUI_to_state() + + return self.STATE + def save_data(self) -> None: self._disable_save() - errors = self.validate_form_data() + errors = self._validate_form_data() if errors: # errors: abort saving and show pop up window @@ -1490,7 +1629,7 @@ class AutoForm(QWidget): # no errors: data can be saved logger_auto_form.info("Erfolg! Alle Daten sind valide.") logger_auto_form.info("Get form data call...") - form_data = self.get_form_data() + form_data = self._get_form_data() logger_auto_form.debug( "\n\n------------>>>>>>>>> Get form data\n%s", pformat(form_data) @@ -1533,14 +1672,14 @@ class AutoForm(QWidget): pformat(db_data), ) - if self.current_id < 0: + if self.STATE.rec_id is None: logger_auto_form.debug("Insert triggered") init_rec_id = self.cfg.data_insert(db_data) assert isinstance(init_rec_id, int) - self.current_id = init_rec_id + self.STATE.rec_id = init_rec_id else: logger_auto_form.debug("Update triggered") - self.cfg.data_update(self.current_id, db_data) + self.cfg.data_update(self.STATE.rec_id, db_data) logger_auto_form.info("Data saved successfully") self.update_triggered.emit() @@ -1549,34 +1688,6 @@ class AutoForm(QWidget): # always re-enable save, even if error occurred self._enable_save() - def validate_form_data(self) -> list[str]: - return validate_form_data(self.widget_registry) - - def reset_form(self) -> None: - reset_form(self.widget_registry) - self.current_id = -1 - self._activation_delete() - - def get_form_data(self) -> dict[str, Any]: - form_data = get_form_data(self.widget_registry) - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call get form data:") - logger_auto_form.debug("Form Data:\n%s", pformat(form_data)) - - if DEBUG_GET_SET: - self.debug_form_data = form_data - - return form_data - - def set_form_data( - self, - data: dict[str, Any], - ) -> None: - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call set form data:") - if DEBUG_GET_SET: - data = self.debug_form_data - - set_form_data(self.widget_registry, data) - class FlexibleDateInput(QWidget): def __init__(self, parent=None): @@ -2454,15 +2565,15 @@ class NoScrollFilter(QObject): return super().eventFilter(obj, event) -class ClickableCell(QFrame): +class MainPage_ClickableCell(QFrame): """cell in the table on the startup screen""" - clicked = Signal(backend.FrontpageCompany) + clicked = Signal(types.MainPageEntry) def __init__( self, text: str, - data_record: backend.FrontpageCompany, + data_record: types.MainPageEntry, ): super().__init__() self.data_record = data_record @@ -2488,7 +2599,7 @@ class ClickableCell(QFrame): self.clicked.emit(self.data_record) -class HeaderCell(QLabel): +class MainPage_HeaderCell(QLabel): def __init__( self, text: str, @@ -2508,19 +2619,19 @@ class HeaderCell(QLabel): """) -class NewEntrySelect_view(QWidget): +class Page_NewInitRec(QWidget): """view for new entry for initial recording ("Grunderfassung")""" back_requested = Signal() # Signal back button - company_requested = Signal(bool) # Signal "Unternehmen" (bool: reset yes/no) - person_requested = Signal() # Signal "Individualperson" + company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen" + person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson" def __init__( self, - session: Session, + state: Page_NewInitRec_State, ): super().__init__() - self.session = session + self.STATE = state layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) @@ -2539,15 +2650,12 @@ class NewEntrySelect_view(QWidget): btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) - btn_company.clicked.connect(lambda: self.company_requested.emit(True)) + btn_company.clicked.connect(self._request_initrec_company) btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) - btn_person.clicked.connect(lambda: self.person_requested.emit()) - # btn_person.clicked.connect( - # lambda: logger_gui.info("[Grunderfassung: Dummy Individualperson] Person gewählt") - # ) + btn_person.clicked.connect(self._request_initrec_person) layout.addWidget(back_btn) layout.addSpacing(15) @@ -2555,6 +2663,44 @@ class NewEntrySelect_view(QWidget): layout.addWidget(btn_company) layout.addWidget(btn_person) + def _request_initrec_company(self) -> None: + req_state = Page_InitRecCompany_State( + session=self.STATE.session, + un_id=None, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.company_requested.emit(req_state) + + def _request_initrec_person(self) -> None: + req_state = Page_InitRecPerson_State( + session=self.STATE.session, + pers_id=None, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.person_requested.emit(req_state) + + def sync_state_to_GUI(self) -> None: + pass + + def sync_GUI_to_state(self) -> None: + pass + + def validate(self) -> None: + pass + + def load_state( + self, + new_state: Page_NewInitRec_State, + ) -> None: + self.sync_state_to_GUI() + + def get_state(self) -> Page_NewInitRec_State: + self.sync_GUI_to_state() + return self.STATE + + def save_data(self) -> None: + pass + CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Unternehmen, @@ -2590,15 +2736,15 @@ CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { class Page_InitRecCompany(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button - update_triggered = Signal() # form saved (data changed for front page) - company_profile_requested = Signal(InitRecId) + update_main_page = Signal() # form saved (data changed for front page) + company_profile_requested = Signal(Page_CompanyProfile_State) def __init__( self, - session: Session, + state: Page_InitRecCompany_State, ): super().__init__() - self.session = session + self.STATE = state # Hauptlayout der Seite outer_layout = QHBoxLayout(self) @@ -2620,9 +2766,7 @@ class Page_InitRecCompany(QWidget): header_layout.setContentsMargins(0, 0, 0, 10) comp_profile_btn = QPushButton("Unternehmensprofil") - comp_profile_btn.clicked.connect( - lambda: self.company_profile_requested.emit(self.current_id) - ) + comp_profile_btn.clicked.connect(self._request_company_profile) comp_profile_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) comp_profile_btn.setMinimumWidth(250) # comp_profile_btn.setMaximumWidth(200) @@ -2668,9 +2812,14 @@ class Page_InitRecCompany(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) - self.auto_form = AutoForm(self.session, cfg=CONFIG_GRUNDERFASSUNG_UNTERNEHMEN) + self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_UNTERNEHMEN + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + ) + self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) - self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) + self.auto_form.update_triggered.connect(lambda: self.update_main_page.emit()) container_layout.addSpacing(15) @@ -2730,13 +2879,50 @@ class Page_InitRecCompany(QWidget): # self.selection_county.style().polish(self.selection_county) # self.selection_county.update() + def _request_company_profile(self) -> None: + req_state = Page_CompanyProfile_State( + session=self.STATE.session, + rec_id=self.STATE.un_id, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.company_profile_requested.emit(req_state) + def reset_form(self) -> None: - self.auto_form.reset_form() + self.auto_form._reset_form() @property - def current_id(self) -> InitRecId: + def current_id(self) -> RecId: return self.auto_form.current_id + def sync_state_to_GUI(self) -> None: + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + rec_id=self.STATE.un_id, + form_data=None, + ) + self.auto_form.load_state(auto_form_state) + + def sync_GUI_to_state(self) -> None: + self.auto_form.sync_GUI_to_state() + + def validate(self) -> None: ... + + def load_state( + self, + new_state: Page_InitRecCompany_State, + ) -> None: + self.STATE.un_id = new_state.un_id + + self.sync_state_to_GUI() + + def get_state(self) -> Page_InitRecCompany_State: + self.sync_GUI_to_state() + return self.STATE + + def save_data(self) -> None: + self.auto_form.save_data() + class Page_InitRecPerson(QWidget): back_main_requested = Signal() # back to main page @@ -2745,10 +2931,10 @@ class Page_InitRecPerson(QWidget): def __init__( self, - session: Session, + state: Page_InitRecPerson_State, ): super().__init__() - self.session = session + self.STATE = state # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2807,7 +2993,9 @@ class Page_InitRecPerson(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) - self.auto_form = AutoForm(self.session, cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) + self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_PERSONEN + auto_form_state = AutoForm_State(session=self.STATE.session, cfg=self.AUTO_FORM_CFG) + self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) @@ -2869,39 +3057,57 @@ class Page_InitRecPerson(QWidget): self.selection_county.update() def reset_form(self) -> None: - self.auto_form.reset_form() + self.auto_form._reset_form() + def sync_state_to_GUI(self) -> None: + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + rec_id=self.STATE.pers_id, + form_data=None, + ) + self.auto_form.load_state(auto_form_state) -class ConsultingType(enum.StrEnum): - PAUSCHAL = enum.auto() - INDIVIUAL = enum.auto() + def sync_GUI_to_state(self) -> None: + self.auto_form.sync_GUI_to_state() + def validate(self) -> None: ... -@dc.dataclass(slots=True, kw_only=True) -class ConsultingRequest: - cons_type: ConsultingType - un_id: InitRecId | None = None - pers_id: InitRecId | None = None + def load_state( + self, + new_state: Page_InitRecPerson_State, + ) -> None: + self.STATE.pers_id = new_state.pers_id + + self.sync_state_to_GUI() + + def get_state(self) -> Page_InitRecPerson_State: + self.sync_GUI_to_state() + return self.STATE + + def save_data(self) -> None: + self.auto_form.save_data() class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page - back_requested = Signal(bool) # back to init rec (bool: reset yes/no) + back_requested = Signal( + Page_InitRecCompany_State + ) # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") # update_triggered = Signal() # form saved (data changed for front page) - consulting_requested = Signal(ConsultingRequest) + consulting_requested = Signal(Page_Consulting_State) def __init__( self, - session: Session, + state: Page_CompanyProfile_State, ): super().__init__() - self.session = session + self.STATE = state # state management - self.current_id: InitRecId = -1 - self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { + self.COMPANY_FEATURES_MASTER_DATA: dict[str, str] = { "ma_unternehmensname": "Name:", "ma_branche": "Branche:", "ma_strasse": "Straße:", @@ -2938,7 +3144,7 @@ class Page_CompanyProfile(QWidget): back_btn_main.setMinimumWidth(200) back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") - back_btn_step.clicked.connect(lambda: self.back_requested.emit(False)) + back_btn_step.clicked.connect(self._request_initrec_company) back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) @@ -2993,7 +3199,7 @@ class Page_CompanyProfile(QWidget): container_layout.addSpacing(30) - self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) + self.title_company_name = MainPage_HeaderCell("Unternehmen: n.v.", font_size=16) container_layout.addWidget(self.title_company_name) container_layout.addSpacing(20) @@ -3009,13 +3215,13 @@ class Page_CompanyProfile(QWidget): left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) left_layout = QVBoxLayout(left_frame) main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) - master_data_label = HeaderCell("Stammdaten & Vermerke") + master_data_label = MainPage_HeaderCell("Stammdaten & Vermerke") master_data_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) left_layout.addWidget(master_data_label) # self.table_master_data = MasterDataTable() - default_labels = tuple(self.COMPANY_FETAURES_MASTER_DATA.values()) + default_labels = tuple(self.COMPANY_FEATURES_MASTER_DATA.values()) self.table_master_data = MasterDataFormWidget(default_labels) left_layout.addWidget(self.table_master_data) left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) @@ -3032,20 +3238,20 @@ class Page_CompanyProfile(QWidget): QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) top_right_layout = QVBoxLayout(top_right_frame) - common_consultancy_label = HeaderCell("Pauschalberatung") + common_consultancy_label = MainPage_HeaderCell("Pauschalberatung") common_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) top_right_layout.addWidget(common_consultancy_label) - top_right_new_btn = QPushButton("+ Hinzufügen") - top_right_new_btn.clicked.connect( + self.add_btn_pauschal = QPushButton("+ Hinzufügen") + self.add_btn_pauschal.clicked.connect( lambda: self._request_consulting(ConsultingType.PAUSCHAL) ) - top_right_new_btn.setSizePolicy( + self.add_btn_pauschal.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - top_right_new_btn.setMinimumHeight(30) - top_right_layout.addWidget(top_right_new_btn) + self.add_btn_pauschal.setMinimumHeight(30) + top_right_layout.addWidget(self.add_btn_pauschal) # bottom right area bottom_right_frame = QFrame() @@ -3054,20 +3260,20 @@ class Page_CompanyProfile(QWidget): QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) bottom_right_layout = QVBoxLayout(bottom_right_frame) - individual_consultancy_label = HeaderCell("Individualberatung") + individual_consultancy_label = MainPage_HeaderCell("Individualberatung") individual_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) bottom_right_layout.addWidget(individual_consultancy_label) - bottom_right_new_btn = QPushButton("+ Hinzufügen") - bottom_right_new_btn.clicked.connect( + self.add_btn_individual = QPushButton("+ Hinzufügen") + self.add_btn_individual.clicked.connect( lambda: self._request_consulting(ConsultingType.INDIVIUAL) ) - bottom_right_new_btn.setSizePolicy( + self.add_btn_individual.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - bottom_right_new_btn.setMinimumHeight(30) - bottom_right_layout.addWidget(bottom_right_new_btn) + self.add_btn_individual.setMinimumHeight(30) + bottom_right_layout.addWidget(self.add_btn_individual) right_column_layout.addWidget(top_right_frame) right_column_layout.addWidget(bottom_right_frame) @@ -3108,16 +3314,74 @@ class Page_CompanyProfile(QWidget): # } # """) + def _request_initrec_company(self) -> None: + req_state = Page_InitRecCompany_State( + session=self.STATE.session, + un_id=self.STATE.rec_id, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.back_requested.emit(req_state) + def _request_consulting( self, cons_type: ConsultingType, ) -> None: - assert self.current_id > -1, ( + assert self.STATE.rec_id is not None, ( "tried to call consulting from comp profile without set ID" ) - # TODO - req = ConsultingRequest(cons_type=cons_type, un_id=self.current_id) - self.consulting_requested.emit(req) + new_state = Page_Consulting_State( + session=self.STATE.session, un_id=self.STATE.rec_id, cons_type=cons_type + ) + self.consulting_requested.emit(new_state) + + def _disable_add_btns(self) -> None: + self.add_btn_pauschal.setEnabled(False) + self.add_btn_individual.setEnabled(False) + + def _enable_add_btns(self) -> None: + self.add_btn_pauschal.setEnabled(True) + self.add_btn_individual.setEnabled(True) + + def sync_state_to_GUI(self) -> None: + if self.STATE.rec_id is None: + # just clear and do nothing else + logger_gui.debug("just reset, ID is None") + self.table_master_data.reset() + self.set_company_name("") + self._disable_add_btns() + return + + self._enable_add_btns() + init_rec = backend.initrec_company_get_initial_recording(self.STATE.rec_id) + ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) + assert ma_id is not None, "no ma_id obtained" + comp_info = backend.initrec_comp_search_get_info(ma_id) + + data: dict[str, Any] = {} + + for feature, value in comp_info.items(): + if feature in self.COMPANY_FEATURES_MASTER_DATA: + data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value + + self.table_master_data.load_data(data) + self.set_company_name(data["Name:"]) + + def sync_GUI_to_state(self) -> None: ... + + def validate(self) -> None: ... + + def load_state( + self, + new_state: Page_CompanyProfile_State, + ) -> None: + logger_gui.debug("Called function to load with ID: %s", new_state.rec_id) + self.STATE.rec_id = new_state.rec_id + + self.sync_state_to_GUI() + + def get_state(self) -> Any: ... + + def save_data(self) -> None: ... def set_company_name(self, name: str) -> None: if not name: @@ -3128,41 +3392,41 @@ class Page_CompanyProfile(QWidget): txt = f"Unternehmen: {name}" self.title_company_name.setText(txt) - def load_from_id( - self, - id_: InitRecId, - ) -> None: - assert id_ is not None, "tried to load with InitRecId None" - assert id_, "tried to load with invalid InitRecId" - logger_gui.debug(f"Called function to load with ID: {id_}") + # def load_from_id( + # self, + # id_: InitRecId, + # ) -> None: + # assert id_ is not None, "tried to load with InitRecId None" + # assert id_, "tried to load with invalid InitRecId" + # logger_gui.debug(f"Called function to load with ID: {id_}") - if id_ < 0: - # just clear and do nothing else - logger_gui.debug("just reset with ID < 0") - self.table_master_data.reset() - self.set_company_name("") - self.current_id = id_ - return + # if id_ < 0: + # # just clear and do nothing else + # logger_gui.debug("just reset with ID < 0") + # self.table_master_data.reset() + # self.set_company_name("") + # self.current_id = id_ + # return - if id_ == self.current_id: - logger_gui.debug("ID did not change. Do nothing") - self.current_id = id_ - return + # if id_ == self.current_id: + # logger_gui.debug("ID did not change. Do nothing") + # self.current_id = id_ + # return - init_rec = backend.initrec_company_get_initial_recording(id_) - ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) - assert ma_id is not None, "no ma_id obtained" - comp_info = backend.initrec_comp_search_get_info(ma_id) + # init_rec = backend.initrec_company_get_initial_recording(id_) + # ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) + # assert ma_id is not None, "no ma_id obtained" + # comp_info = backend.initrec_comp_search_get_info(ma_id) - data: dict[str, Any] = {} + # data: dict[str, Any] = {} - for feature, value in comp_info.items(): - if feature in self.COMPANY_FETAURES_MASTER_DATA: - data[self.COMPANY_FETAURES_MASTER_DATA[feature]] = value + # for feature, value in comp_info.items(): + # if feature in self.COMPANY_FEATURES_MASTER_DATA: + # data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value - self.table_master_data.load_data(data) - self.set_company_name(data["Name:"]) - self.current_id = id_ + # self.table_master_data.load_data(data) + # self.set_company_name(data["Name:"]) + # self.current_id = id_ class MasterDataTable(QWidget): @@ -3342,46 +3606,6 @@ class MasterDataFormWidget(QWidget): """) -@dc.dataclass(slots=True) -class Session: - user_id: UserId - user_name: str - - -@dc.dataclass(slots=True) -class Page_Consulting_ConsultingSession_State: - nutzer_id: UserId # must be set with the session (always known) - nutzer_name: str # must be set with the session (always known) - beratung_id: int | None = None # known and not known entries - zeitstempel: datetime.datetime = dc.field( - default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) - ) - ansprechpartner: str | None = None - interaction_scheme: str | None = None - topic: str | None = ( - None # TODO should be linked to something, target unclear (need specification by customer) - ) - anmerkungen: str | None = None - rueckmeldung: str | None = None - - -@dc.dataclass(slots=True) -class Page_Consulting_Table_State: - rows: list[Page_Consulting_ConsultingSession] = dc.field(default_factory=list) - row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) - - -@dc.dataclass(slots=True) -class Page_Consulting_State: - session: Session - un_id: InitRecId | None = None - pers_id: InitRecId | None = None - cons_type: ConsultingType | None = None - cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( - default_factory=list - ) - - class Page_Consulting(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal(bool) # back to init rec (bool: reset yes/no) @@ -3604,14 +3828,14 @@ class Page_Consulting(QWidget): self.table_sessions.get_data() # TODO placeholder, check if needed - def _sync_state_to_ui(self) -> None: + def sync_state_to_GUI(self) -> None: match self.STATE.cons_type: case ConsultingType.PAUSCHAL: self.type_pauschal_btn.setChecked(True) case ConsultingType.INDIVIUAL: self.type_individual_btn.setChecked(True) - def _sync_ui_to_state(self) -> None: ... + def sync_GUI_to_state(self) -> None: ... def type_consulting_changed( self, @@ -3630,18 +3854,18 @@ class Page_Consulting(QWidget): logger_gui.debug("[Consulting Page] Selected cons_type: %s", cons_type) self.STATE.cons_type = cons_type - def load_from_request( + def load_from_state( self, - req: ConsultingRequest, + new_state: Page_Consulting_State, ) -> None: # TODO add correct loading behaviour # TODO add validation (correct type with necessary information) - logger_gui.debug("[Consulting] Loading with request:\n%s", req) - self.STATE.un_id = req.un_id - self.STATE.pers_id = req.pers_id - self.STATE.cons_type = req.cons_type + logger_gui.debug("[Consulting] Loading with request:\n%s", new_state) + self.STATE.un_id = new_state.un_id + self.STATE.pers_id = new_state.pers_id + self.STATE.cons_type = new_state.cons_type - self._sync_state_to_ui() + self.sync_state_to_GUI() class Page_Consulting_ConsultingSession(QWidget): @@ -3736,9 +3960,9 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - self._sync_state_to_gui() # ?? only needed if entry is from database? + self.sync_state_to_gui() # ?? only needed if entry is from database? - def _sync_state_to_gui(self) -> None: + def sync_state_to_gui(self) -> None: # TODO set correct timestamp logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE @@ -3751,8 +3975,7 @@ class Page_Consulting_ConsultingSession(QWidget): set_widget_value(self.plain_text, self.STATE.anmerkungen) set_widget_value(self.response, self.STATE.rueckmeldung) - def _sync_gui_to_state(self) -> None: - # TODO: get timestamp from custom field self.STATE.timestamp + def sync_gui_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() self.STATE.ansprechpartner = get_widget_value(self.contact) self.STATE.interaction_scheme = get_widget_value(self.contact_type) @@ -3761,7 +3984,7 @@ class Page_Consulting_ConsultingSession(QWidget): self.STATE.rueckmeldung = get_widget_value(self.response) def get_data(self) -> Page_Consulting_ConsultingSession_State: - self._sync_gui_to_state() + self.sync_gui_to_state() return self.STATE @@ -3963,11 +4186,11 @@ class Page_Consulting_Table(QWidget): while self.STATE.rows: self.remove_row(self.STATE.rows[0]) - def _sync_state_to_gui(self) -> None: + def sync_state_to_gui(self) -> None: for row_state in self.STATE.row_states: self.add_row(row_state) - def _sync_gui_to_state(self) -> None: + def sync_gui_to_state(self) -> None: row_data: list[Page_Consulting_ConsultingSession_State] = [] for i in range(self.container_layout.count() - 1): @@ -3984,7 +4207,7 @@ class Page_Consulting_Table(QWidget): def get_data(self) -> Page_Consulting_Table_State: logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") - self._sync_gui_to_state() + self.sync_gui_to_state() logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) @@ -4037,26 +4260,30 @@ class MainWindow(QMainWindow): self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' - self.new_entry_select = NewEntrySelect_view(self.session) - self.new_entry_select.back_requested.connect(self.show_main_page) - self.new_entry_select.company_requested.connect(self.show_page_initrec_company) - self.new_entry_select.person_requested.connect(self.show_page_initrec_person) - self.stack.addWidget(self.new_entry_select) + new_initrec_select_state = Page_NewInitRec_State(session=self.session) + self.new_initrec_select = Page_NewInitRec(new_initrec_select_state) + self.new_initrec_select.back_requested.connect(self.show_main_page) + self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) + self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) + self.stack.addWidget(self.new_initrec_select) # SITE: 'Grunderfassung Unternehmen' - self.initrec_company = Page_InitRecCompany(self.session) + initrec_company_state = Page_InitRecCompany_State(session=self.session) + self.initrec_company = Page_InitRecCompany(initrec_company_state) self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) - self.initrec_company.update_triggered.connect(self.update_grid) + self.initrec_company.update_main_page.connect(self.update_grid) self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' - self.initrec_person = Page_InitRecPerson(self.session) + initrec_person_state = Page_InitRecPerson_State(session=self.session) + self.initrec_person = Page_InitRecPerson(initrec_person_state) self.initrec_person.back_main_requested.connect(self.show_main_page) self.initrec_person.back_requested.connect(self.show_new_entry_select) self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' - self.company_profile = Page_CompanyProfile(self.session) + comp_profile_state = Page_CompanyProfile_State(session=self.session) + self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) self.company_profile.back_requested.connect(self.show_page_initrec_company) self.company_profile.consulting_requested.connect(self.show_page_consulting) @@ -4152,9 +4379,9 @@ class MainWindow(QMainWindow): "Datum", ] for col_idx, title in enumerate(headers): - self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) + self.header_grid.addWidget(MainPage_HeaderCell(title), self.current_row, col_idx) - self.update_grid() + # self.update_grid() return main_widget @@ -4164,60 +4391,46 @@ class MainWindow(QMainWindow): def update_grid(self) -> None: clear_layout(self.grid) - data = backend.front_get_company_list() + data = backend.main_page_get_entries() for entry in data: self.add_row_to_grid(entry) def add_row_to_grid( self, - entry: backend.FrontpageCompany, + entry: types.MainPageEntry, ): row = self.current_row # NAME - cell_name = ClickableCell(entry.name, entry) + cell_name = MainPage_ClickableCell(entry.display_name, entry) cell_name.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_name, row, 0) # DATE cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) - cell_date = ClickableCell(cell_date_name, entry) + cell_date = MainPage_ClickableCell(cell_date_name, entry) cell_date.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_date, row, 4) - # self.grid.addWidget(ClickableCell(entry["c2"], entry), row, 1) - - # c3_value = entry.get("c3") - # if c3_value: - # self.grid.addWidget(ClickableCell(c3_value, entry), row, 2) - # else: - # empty_box = QFrame() - # empty_box.setStyleSheet( - # "QFrame { background-color: #f8fafc; border: 2px - # dashed #e2e8f0; border-radius: 8px; }" - # ) - # self.grid.addWidget(empty_box, row, 2) - - # self.grid.addWidget(ClickableCell(entry.get("c4", ""), entry), row, 3) - # self.grid.addWidget(ClickableCell(entry.get("date", ""), entry), row, 4) - self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen def goto_initial_recording( self, - data: backend.FrontpageCompany, + data: types.MainPageEntry, ): - if data.is_company: - self.initrec_company.auto_form.load_data(data.erfassung_id) - self.stack.setCurrentWidget(self.initrec_company) - else: - self.initrec_person.auto_form.load_data(data.erfassung_id) - self.stack.setCurrentWidget(self.initrec_person) + target: QWidget + if data.type is types.InitRecType.COMPANY: + new_state = Page_InitRecCompany_State(session=self.session, un_id=data.rec_id) + self.initrec_company.load_state(new_state) + target = self.initrec_company + # self.stack.setCurrentWidget(self.initrec_company) + elif data.type is types.InitRecType.PERSON: + new_state = Page_InitRecPerson_State(session=self.session, pers_id=data.rec_id) + self.initrec_person.load_state(new_state) + target = self.initrec_person + # self.stack.setCurrentWidget(self.initrec_person) - # 1. Daten an die Detail-Seite übergeben - # self.detail_page.update_content(data) - # 2. Auf die Detail-Seite umblättern - # self.stack.setCurrentWidget(self.detail_page) + self._navigate_to_target(target) def _navigate_to_target( self, @@ -4242,30 +4455,34 @@ class MainWindow(QMainWindow): self._navigate_to_target(self.main_page) def show_new_entry_select(self): - self._navigate_to_target(self.new_entry_select) + self._navigate_to_target(self.new_initrec_select) - def show_page_initrec_company(self, reset: bool): - if reset: - self.initrec_company.reset_form() + def show_page_initrec_company( + self, + new_state: Page_InitRecCompany_State, + ): + self.initrec_company.load_state(new_state) self._navigate_to_target(self.initrec_company) - def show_page_initrec_person(self): - # TODO: similar to company with reset? - self.initrec_person.reset_form() + def show_page_initrec_person( + self, + new_state: Page_InitRecPerson_State, + ): + self.initrec_person.load_state(new_state) self._navigate_to_target(self.initrec_person) def show_page_company_profile( self, - id_: InitRecId, + new_state: Page_CompanyProfile_State, ) -> None: - self.company_profile.load_from_id(id_) + self.company_profile.load_state(new_state) self._navigate_to_target(self.company_profile) def show_page_consulting( self, - req: ConsultingRequest, + new_state: Page_Consulting_State, ) -> None: - self.consulting_page.load_from_request(req) + self.consulting_page.load_from_state(new_state) self._navigate_to_target(self.consulting_page) # --- MENÜ LOGIK --- diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 38575a7..b250ea4 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -1,13 +1,84 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +import dataclasses as dc +import datetime +import enum +from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: import polars as pl UserId: TypeAlias = int -InitRecId: TypeAlias = int +RecId: TypeAlias = int ExtMaId: TypeAlias = int ExtAnId: TypeAlias = int PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] + + +class InitRecType(enum.IntEnum): + COMPANY = enum.auto() + PERSON = enum.auto() + + +# // database interaction data structures +# ** externals +class CompanyInfo(TypedDict): + ma_id: str + wce_id: str + ma_unternehmensname: str + ma_branche: str + ma_strasse: str + ma_hausnummer: str + ma_plz: str + ma_ort: str + ma_plz_postfach: str + ma_postfach: str + ma_website: str + ma_mail: str + ma_telefonnummer: str + ma_faxnummer: str + ma_ersteintrag_datum: str + ma_aktualisierung_datum: str + ma_aktualisierung_nutzer: str + ma_sollprozess: str + ma_auslaendische_mitarbeiter: str + ma_quelle_information: str + ma_bemerkung: str + ma_kontakt: str + ma_schlagworte: str + ma_archiviert: str + + +class ContactPersonInfo(TypedDict): + an_id: str + ma_id: str + wce_id: str + st_id: str + an_sachgebiet: str + an_anrede: str + an_titel: str + an_nachname: str + an_vorname: str + an_position: str + an_mail: str + an_festnetz: str + an_mobil: str + an_faxnummer: str + an_hauptansprechpartner: str + an_anrede_anschrift: str + an_bemerkung: str + an_aktualisierung_datum: str + an_aktualisierung_nutzer: str + an_letztes_kontaktdatum: str + an_ersteintrag_datum: str + an_archiviert: str + + +# ** internals +@dc.dataclass(slots=True) +class MainPageEntry: + rec_id: RecId + display_name: str + Metadaten_aktualisierung: datetime.datetime + type: InitRecType -- 2.34.1 From 0bb6e98341c425d134718629bfa9ef75900b289a Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 6 Jul 2026 15:59:10 +0200 Subject: [PATCH 15/74] fix QSS render issue after renaming --- src/wce_crm/gui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 8f02800..26c7a75 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -2578,12 +2578,12 @@ class MainPage_ClickableCell(QFrame): super().__init__() self.data_record = data_record self.setStyleSheet(""" - ClickableCell { + MainPage_ClickableCell { background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; } - ClickableCell:hover { + MainPage_ClickableCell:hover { background-color: #eff6ff; border: 1px solid #60a5fa; } @@ -2608,7 +2608,7 @@ class MainPage_HeaderCell(QLabel): super().__init__(text) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setStyleSheet(f""" - HeaderCell {{ + MainPage_HeaderCell {{ background-color: #e2e8f0; color: #475569; font-weight: bold; @@ -4381,7 +4381,7 @@ class MainWindow(QMainWindow): for col_idx, title in enumerate(headers): self.header_grid.addWidget(MainPage_HeaderCell(title), self.current_row, col_idx) - # self.update_grid() + self.update_grid() return main_widget -- 2.34.1 From cae623bc19361e1c437b2c9e2160c3fa788cd816 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 6 Jul 2026 16:09:10 +0200 Subject: [PATCH 16/74] add main state and remove unnecessary lines --- src/wce_crm/gui.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 26c7a75..ec826cd 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -313,6 +313,12 @@ class Session: user_name: str +@dc.dataclass(slots=True) +class Page_MainPage_State: + session: Session + row_count: int = 0 + + @dc.dataclass(slots=True) class Page_NewInitRec_State: session: Session @@ -2890,10 +2896,6 @@ class Page_InitRecCompany(QWidget): def reset_form(self) -> None: self.auto_form._reset_form() - @property - def current_id(self) -> RecId: - return self.auto_form.current_id - def sync_state_to_GUI(self) -> None: auto_form_state = AutoForm_State( session=self.STATE.session, @@ -4242,7 +4244,8 @@ class MainWindow(QMainWindow): super().__init__() # STATE MANAGEMENT - self.session = Session(user_id=42, user_name="TEST-USER") + session = Session(user_id=42, user_name="TEST-USER") + self.STATE = Page_MainPage_State(session=session) self._history_stack: list[int] = [] self.setWindowTitle("Master") @@ -4260,14 +4263,14 @@ class MainWindow(QMainWindow): self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' - new_initrec_select_state = Page_NewInitRec_State(session=self.session) + new_initrec_select_state = Page_NewInitRec_State(session=self.STATE.session) self.new_initrec_select = Page_NewInitRec(new_initrec_select_state) self.new_initrec_select.back_requested.connect(self.show_main_page) self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) self.stack.addWidget(self.new_initrec_select) # SITE: 'Grunderfassung Unternehmen' - initrec_company_state = Page_InitRecCompany_State(session=self.session) + initrec_company_state = Page_InitRecCompany_State(session=self.STATE.session) self.initrec_company = Page_InitRecCompany(initrec_company_state) self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) @@ -4275,21 +4278,21 @@ class MainWindow(QMainWindow): self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' - initrec_person_state = Page_InitRecPerson_State(session=self.session) + initrec_person_state = Page_InitRecPerson_State(session=self.STATE.session) self.initrec_person = Page_InitRecPerson(initrec_person_state) self.initrec_person.back_main_requested.connect(self.show_main_page) self.initrec_person.back_requested.connect(self.show_new_entry_select) self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' - comp_profile_state = Page_CompanyProfile_State(session=self.session) + comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) self.company_profile.back_requested.connect(self.show_page_initrec_company) self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) # SITE: 'Beratungsgespräch' - self.consulting_page = Page_Consulting(self.session) + self.consulting_page = Page_Consulting(self.STATE.session) self.consulting_page.back_main_requested.connect(self.show_main_page) # self.consulting_page.back_requested.connect( # self.show_page_company_profile_without_load @@ -4420,15 +4423,19 @@ class MainWindow(QMainWindow): ): target: QWidget if data.type is types.InitRecType.COMPANY: - new_state = Page_InitRecCompany_State(session=self.session, un_id=data.rec_id) + new_state = Page_InitRecCompany_State( + session=self.STATE.session, un_id=data.rec_id + ) self.initrec_company.load_state(new_state) target = self.initrec_company - # self.stack.setCurrentWidget(self.initrec_company) elif data.type is types.InitRecType.PERSON: - new_state = Page_InitRecPerson_State(session=self.session, pers_id=data.rec_id) + new_state = Page_InitRecPerson_State( + session=self.STATE.session, pers_id=data.rec_id + ) self.initrec_person.load_state(new_state) target = self.initrec_person - # self.stack.setCurrentWidget(self.initrec_person) + else: + raise TypeError(f"Unknown init rec type: {data.type}") self._navigate_to_target(target) -- 2.34.1 From 770ae11ad6706794169baff2f333906783174498 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 8 Jul 2026 10:30:04 +0200 Subject: [PATCH 17/74] added more specific interfaces and more concise state handling --- src/wce_crm/gui.py | 247 ++++++++++++++++++++++++++++++--------------- 1 file changed, 165 insertions(+), 82 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index ec826cd..49fd522 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -16,6 +16,7 @@ from typing import ( TYPE_CHECKING, Any, Final, + Generic, Literal, Protocol, TypeAlias, @@ -103,8 +104,9 @@ if TYPE_CHECKING: K = TypeVar("K") V = TypeVar("V") -T = TypeVar("T", bound="QWidget") - +T = TypeVar("T") +W = TypeVar("W", bound="QWidget") +M = TypeVar("M", bound="Module") DEBUG: bool = True DEBUG_SEARCH_WIDGET: bool = False @@ -224,9 +226,9 @@ class CustomForm(Protocol): class Module(Protocol): - def sync_state_to_GUI(self) -> None: ... + def __sync_state_to_GUI(self) -> None: ... # type: ignore - def sync_GUI_to_state(self) -> None: ... + def __sync_GUI_to_state(self) -> None: ... # type: ignore def validate(self) -> None: ... @@ -314,24 +316,29 @@ class Session: @dc.dataclass(slots=True) -class Page_MainPage_State: +class PageState(Generic[M]): + child_modules: list[M] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_MainPage_State(PageState[Module]): session: Session row_count: int = 0 -@dc.dataclass(slots=True) -class Page_NewInitRec_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_NewInitRec_State(PageState[Module]): session: Session -@dc.dataclass(slots=True) -class Page_InitRecCompany_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecCompany_State(PageState[Module]): session: Session un_id: RecId | None = None -@dc.dataclass(slots=True) -class Page_InitRecPerson_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecPerson_State(PageState[Module]): session: Session pers_id: RecId | None = None @@ -341,8 +348,8 @@ class ConsultingType(enum.StrEnum): INDIVIUAL = enum.auto() -@dc.dataclass(slots=True) -class Page_Consulting_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_State(PageState[Module]): session: Session un_id: RecId | None = None pers_id: RecId | None = None @@ -352,8 +359,8 @@ class Page_Consulting_State: ) -@dc.dataclass(slots=True) -class Page_Consulting_ConsultingSession_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_ConsultingSession_State(PageState[Module]): nutzer_id: UserId # must be set with the session (always known) nutzer_name: str # must be set with the session (always known) beratung_id: int | None = None # known and not known entries @@ -369,20 +376,19 @@ class Page_Consulting_ConsultingSession_State: rueckmeldung: str | None = None -@dc.dataclass(slots=True) -class Page_Consulting_Table_State: - rows: list[Page_Consulting_ConsultingSession] = dc.field(default_factory=list) +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) -@dc.dataclass(slots=True) -class Page_CompanyProfile_State: +@dc.dataclass(slots=True, kw_only=True) +class Page_CompanyProfile_State(PageState[Module]): session: Session rec_id: RecId | None = None def _add_widget_to_layout( - form_field: FormField[T], + form_field: FormField[W], widget: QWidget, target_layout: QLayout, widget_only: bool = False, @@ -407,10 +413,10 @@ def _add_widget_to_layout( def build_and_add_widget( - form_field: FormField[T], + form_field: FormField[W], parent_layout: QLayout, prefix: str = "", -) -> tuple[str, T]: +) -> tuple[str, W]: no_scroll_filter = NoScrollFilter(parent_layout) if prefix and form_field.key: full_key = f"{prefix}{COLUMN_SEP}{form_field.key}" if prefix else form_field.key @@ -603,7 +609,7 @@ def build_and_add_widget( _add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only) - return full_key, cast(T, widget) + return full_key, cast(W, widget) def _build_ui_recursively( @@ -1599,7 +1605,7 @@ class AutoForm(QWidget): def sync_state_to_GUI(self) -> None: self._load_data(self.STATE.rec_id) - def sync_GUI_to_state(self) -> None: + def __sync_GUI_to_state(self) -> None: form_data = self._get_form_data() self.STATE.form_data = form_data @@ -1609,12 +1615,13 @@ class AutoForm(QWidget): self, new_state: AutoForm_State, ) -> None: - self.STATE.rec_id = new_state.rec_id - self.STATE.form_data = new_state.form_data + load_state(self.STATE, new_state) + # self.STATE.rec_id = new_state.rec_id + # self.STATE.form_data = new_state.form_data self.sync_state_to_GUI() def get_state(self) -> AutoForm_State: - self.sync_GUI_to_state() + self.__sync_GUI_to_state() return self.STATE @@ -2685,10 +2692,10 @@ class Page_NewInitRec(QWidget): logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) self.person_requested.emit(req_state) - def sync_state_to_GUI(self) -> None: + def __sync_state_to_GUI(self) -> None: pass - def sync_GUI_to_state(self) -> None: + def __sync_GUI_to_state(self) -> None: pass def validate(self) -> None: @@ -2698,10 +2705,10 @@ class Page_NewInitRec(QWidget): self, new_state: Page_NewInitRec_State, ) -> None: - self.sync_state_to_GUI() + self.__sync_state_to_GUI() def get_state(self) -> Page_NewInitRec_State: - self.sync_GUI_to_state() + self.__sync_GUI_to_state() return self.STATE def save_data(self) -> None: @@ -2896,7 +2903,7 @@ class Page_InitRecCompany(QWidget): def reset_form(self) -> None: self.auto_form._reset_form() - def sync_state_to_GUI(self) -> None: + def __sync_state_to_GUI(self) -> None: auto_form_state = AutoForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, @@ -2905,8 +2912,9 @@ class Page_InitRecCompany(QWidget): ) self.auto_form.load_state(auto_form_state) - def sync_GUI_to_state(self) -> None: - self.auto_form.sync_GUI_to_state() + def __sync_GUI_to_state(self) -> None: + # TODO behaviour not consistent + self.auto_form.__sync_GUI_to_state() def validate(self) -> None: ... @@ -2916,10 +2924,10 @@ class Page_InitRecCompany(QWidget): ) -> None: self.STATE.un_id = new_state.un_id - self.sync_state_to_GUI() + self.__sync_state_to_GUI() def get_state(self) -> Page_InitRecCompany_State: - self.sync_GUI_to_state() + self.__sync_GUI_to_state() return self.STATE def save_data(self) -> None: @@ -3061,7 +3069,7 @@ class Page_InitRecPerson(QWidget): def reset_form(self) -> None: self.auto_form._reset_form() - def sync_state_to_GUI(self) -> None: + def __sync_state_to_GUI(self) -> None: auto_form_state = AutoForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, @@ -3070,8 +3078,8 @@ class Page_InitRecPerson(QWidget): ) self.auto_form.load_state(auto_form_state) - def sync_GUI_to_state(self) -> None: - self.auto_form.sync_GUI_to_state() + def __sync_GUI_to_state(self) -> None: + self.auto_form.__sync_GUI_to_state() def validate(self) -> None: ... @@ -3081,10 +3089,10 @@ class Page_InitRecPerson(QWidget): ) -> None: self.STATE.pers_id = new_state.pers_id - self.sync_state_to_GUI() + self.__sync_state_to_GUI() def get_state(self) -> Page_InitRecPerson_State: - self.sync_GUI_to_state() + self.__sync_GUI_to_state() return self.STATE def save_data(self) -> None: @@ -3344,7 +3352,7 @@ class Page_CompanyProfile(QWidget): self.add_btn_pauschal.setEnabled(True) self.add_btn_individual.setEnabled(True) - def sync_state_to_GUI(self) -> None: + def __sync_state_to_GUI(self) -> None: if self.STATE.rec_id is None: # just clear and do nothing else logger_gui.debug("just reset, ID is None") @@ -3368,7 +3376,7 @@ class Page_CompanyProfile(QWidget): self.table_master_data.load_data(data) self.set_company_name(data["Name:"]) - def sync_GUI_to_state(self) -> None: ... + def __sync_GUI_to_state(self) -> None: ... def validate(self) -> None: ... @@ -3379,7 +3387,7 @@ class Page_CompanyProfile(QWidget): logger_gui.debug("Called function to load with ID: %s", new_state.rec_id) self.STATE.rec_id = new_state.rec_id - self.sync_state_to_GUI() + self.__sync_state_to_GUI() def get_state(self) -> Any: ... @@ -3737,6 +3745,7 @@ class Page_Consulting(QWidget): # container_layout.addSpacing(50) table_state = Page_Consulting_Table_State() self.table_sessions = Page_Consulting_Table(site_state=self.STATE, state=table_state) + self.STATE.child_modules.append(self.table_sessions) container_layout.addWidget(self.table_sessions) # container_layout.addSpacing(50) @@ -3827,23 +3836,31 @@ class Page_Consulting(QWidget): # self.cons_entries.remove(row_widget) def _debug_get_data_table(self) -> None: - self.table_sessions.get_data() + self.table_sessions.get_state() # TODO placeholder, check if needed - def sync_state_to_GUI(self) -> None: + def __sync_state_to_GUI(self) -> None: match self.STATE.cons_type: case ConsultingType.PAUSCHAL: self.type_pauschal_btn.setChecked(True) case ConsultingType.INDIVIUAL: self.type_individual_btn.setChecked(True) - def sync_GUI_to_state(self) -> None: ... + # call to child modules not needed because these are initialised by their own + # "load_state" method which again calls the modules sync method + + def __sync_GUI_to_state(self) -> None: + # call to child modules not needed because their syncing method is called when + # the "get_state" method is called --> this has to be managed within this method + + table_state = self.table_sessions.get_state() + self.STATE.cons_sessions = table_state.row_states def type_consulting_changed( self, button_id: int, ) -> None: - logger_gui.debug("[Consulting Page] Trigger type buttons...") + logger_page_consulting.debug("[Consulting Page] Trigger type buttons...") cons_type: ConsultingType if button_id == 0: @@ -3853,7 +3870,7 @@ class Page_Consulting(QWidget): else: raise RuntimeError(f"No consulting type defined for button id: {button_id}") - logger_gui.debug("[Consulting Page] Selected cons_type: %s", cons_type) + logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) self.STATE.cons_type = cons_type def load_from_state( @@ -3862,12 +3879,15 @@ class Page_Consulting(QWidget): ) -> None: # TODO add correct loading behaviour # TODO add validation (correct type with necessary information) - logger_gui.debug("[Consulting] Loading with request:\n%s", new_state) - self.STATE.un_id = new_state.un_id - self.STATE.pers_id = new_state.pers_id - self.STATE.cons_type = new_state.cons_type + logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) + load_state(self.STATE, new_state) + # self.STATE.un_id = new_state.un_id + # self.STATE.pers_id = new_state.pers_id + # self.STATE.cons_type = new_state.cons_type + table_state = Page_Consulting_Table_State(row_states=self.STATE.cons_sessions) + self.table_sessions.load_state(table_state) - self.sync_state_to_GUI() + self.__sync_state_to_GUI() class Page_Consulting_ConsultingSession(QWidget): @@ -3885,12 +3905,15 @@ class Page_Consulting_ConsultingSession(QWidget): assert isinstance(state, Page_Consulting_ConsultingSession_State) self.SITE_STATE = site_state self.STATE = state + layout = QHBoxLayout(self) layout.setContentsMargins(0, 2, 0, 2) # Sehr kompakt layout.setSpacing(10) layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) ROW_HEIGHT = 70 + # ?? widget registry? + user_field_def = FormField[QLineEdit]( "", FormFieldType.TEXT, @@ -3962,10 +3985,9 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - self.sync_state_to_gui() # ?? only needed if entry is from database? + self.__sync_state_to_GUI() # ?? only needed if entry is from database? - def sync_state_to_gui(self) -> None: - # TODO set correct timestamp + def __sync_state_to_GUI(self) -> None: logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) @@ -3977,7 +3999,7 @@ class Page_Consulting_ConsultingSession(QWidget): set_widget_value(self.plain_text, self.STATE.anmerkungen) set_widget_value(self.response, self.STATE.rueckmeldung) - def sync_gui_to_state(self) -> None: + def __sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() self.STATE.ansprechpartner = get_widget_value(self.contact) self.STATE.interaction_scheme = get_widget_value(self.contact_type) @@ -3985,11 +4007,47 @@ class Page_Consulting_ConsultingSession(QWidget): self.STATE.anmerkungen = get_widget_value(self.plain_text) self.STATE.rueckmeldung = get_widget_value(self.response) - def get_data(self) -> Page_Consulting_ConsultingSession_State: - self.sync_gui_to_state() + def get_state(self) -> Page_Consulting_ConsultingSession_State: + self.__sync_GUI_to_state() return self.STATE + def validate(self) -> None: + pass + + def save_data(self) -> None: + pass + + def load_state( + self, + new_state: Page_Consulting_ConsultingSession_State, + ) -> None: + load_state(self.STATE, new_state) + self.__sync_state_to_GUI() + + +def load_state( + target: T, + source: T, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and atrget must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + for field in dc.fields(source): + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) + class Page_Consulting_Table(QWidget): """child of 'Page_Consulting'""" @@ -4004,6 +4062,7 @@ class Page_Consulting_Table(QWidget): self.resize(600, 400) self.STATE = state + # TODO eliminate site state self.SITE_STATE = site_state main_layout = QVBoxLayout(self) @@ -4154,11 +4213,6 @@ class Page_Consulting_Table(QWidget): # } # """) - if DEBUG: - # add rows for debugging - self.add_row() - self.add_row() - def add_row( self, row_state: Page_Consulting_ConsultingSession_State | None = None, @@ -4174,47 +4228,76 @@ class Page_Consulting_Table(QWidget): index = self.container_layout.count() - 1 self.container_layout.insertWidget(index, row) - self.STATE.rows.append(row) + self.STATE.child_modules.append(row) def remove_row( self, table_row_widget: Page_Consulting_ConsultingSession, ) -> None: self.container_layout.removeWidget(table_row_widget) - self.STATE.rows.remove(table_row_widget) + self.STATE.child_modules.remove(table_row_widget) table_row_widget.deleteLater() def reset(self) -> None: - while self.STATE.rows: - self.remove_row(self.STATE.rows[0]) + while self.STATE.child_modules: + self.remove_row(self.STATE.child_modules[0]) - def sync_state_to_gui(self) -> None: + def __sync_state_to_GUI(self) -> None: + self.reset() for row_state in self.STATE.row_states: self.add_row(row_state) - def sync_gui_to_state(self) -> None: + def __sync_GUI_to_state(self) -> None: + logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") row_data: list[Page_Consulting_ConsultingSession_State] = [] - for i in range(self.container_layout.count() - 1): - item = self.container_layout.itemAt(i) - assert item, "item not found" - widget = item.widget() - assert widget, "no widget obtained" + logger_page_consulting.debug( + "[Page -- Consulting] Table - Child modules: %s", + pformat(self.STATE.child_modules), + ) + for row in self.STATE.child_modules: + row_data.append(row.get_state()) - if isinstance(widget, Page_Consulting_ConsultingSession): - row_data.append(widget.get_data()) + # TODO remove + # for i in range(self.container_layout.count() - 1): + # item = self.container_layout.itemAt(i) + # assert item, "item not found" + # widget = item.widget() + # assert widget, "no widget obtained" - assert len(row_data) == len(self.STATE.rows), "mismatch in row count and data length" + # if isinstance(widget, Page_Consulting_ConsultingSession): + # row_data.append(widget.get_state()) + + assert len(row_data) == len(self.STATE.child_modules), ( + "mismatch in row count and data length" + ) self.STATE.row_states = row_data - def get_data(self) -> Page_Consulting_Table_State: + def get_state(self) -> Page_Consulting_Table_State: logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") - self.sync_gui_to_state() + self.__sync_GUI_to_state() logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) return self.STATE + def load_state( + self, + new_state: Page_Consulting_Table_State, + ) -> None: + logger_page_consulting.debug( + "[Page -- Consulting] Table: Call loading from state, %s", new_state + ) + self.STATE.row_states = new_state.row_states + + self.__sync_state_to_GUI() + + def validate(self) -> None: + pass + + def save_data(self) -> None: + pass + #################################################################### -- 2.34.1 From fae94d01a31236f0c1450b0c3a69b1d834e48b2e Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 8 Jul 2026 11:38:03 +0200 Subject: [PATCH 18/74] consulting page disable type selection if pre-defined --- src/wce_crm/gui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 49fd522..ed0a70e 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3846,6 +3846,12 @@ class Page_Consulting(QWidget): case ConsultingType.INDIVIUAL: self.type_individual_btn.setChecked(True) + # deactivate if at least one ID is set (type is pre-defined by how + # the site is called) + if self.STATE.un_id or self.STATE.pers_id: + self.type_pauschal_btn.setEnabled(False) + self.type_individual_btn.setEnabled(False) + # call to child modules not needed because these are initialised by their own # "load_state" method which again calls the modules sync method -- 2.34.1 From 046adfd3df3dd3aa81688ad873ed1345dab02776 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 8 Jul 2026 12:00:20 +0200 Subject: [PATCH 19/74] updated config for `Ruff` and `PyRight` --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f47c991..04176df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,14 @@ license = {text = "LicenseRef-Proprietary"} requires = ["pdm-backend"] build-backend = "pdm.backend" +[tool.pyright] +typeCheckingMode = "basic" +pythonVersion = "3.11" +reportUnusedImport = "none" +reportUnusedVariable = "warning" +reportUnusedClass = "warning" +reportUnusedFunction = "warning" +reportPrivateUsage = "error" [tool.ruff] line-length = 94 @@ -26,7 +34,7 @@ quote-style = "double" skip-magic-trailing-comma = false [tool.ruff.lint] -select = ["E", "F", "I"] +select = ["E", "F", "I", "SLF"] [tool.ruff.lint.isort] extra-standard-library = ["typing_extensions"] -- 2.34.1 From 16ae57368797ed08a3e69eb6f051bb26ca9513b6 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 8 Jul 2026 15:28:50 +0200 Subject: [PATCH 20/74] defining common interfaces for modules --- src/wce_crm/gui.py | 564 ++++++++++++++++++++++++----------------- src/wce_crm/logging.py | 3 + 2 files changed, 334 insertions(+), 233 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index ed0a70e..66475df 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -93,6 +93,7 @@ from wce_crm.form_defs import ( ) from wce_crm.logging import ( logger_auto_form, + logger_custom_fields, logger_get_data, logger_gui, logger_page_consulting, @@ -107,6 +108,7 @@ V = TypeVar("V") T = TypeVar("T") W = TypeVar("W", bound="QWidget") M = TypeVar("M", bound="Module") +S = TypeVar("S", bound="PageState") DEBUG: bool = True DEBUG_SEARCH_WIDGET: bool = False @@ -225,16 +227,45 @@ class CustomForm(Protocol): def validate_form_data(self) -> list[str]: ... -class Module(Protocol): - def __sync_state_to_GUI(self) -> None: ... # type: ignore +class WrapperModule(Protocol): + def _sync_state_to_GUI(self) -> None: + """build states for all child modules and use their `load_state` method + to initialise them with the new data + """ + ... - def __sync_GUI_to_state(self) -> None: ... # type: ignore - - def validate(self) -> None: ... + def _sync_GUI_to_state(self) -> None: + """call `get_state` method on all child modules and assign respective + properties to state of parent module + """ + ... def load_state(self, new_state: Any) -> None: ... - def get_state(self) -> Any: ... + def get_state(self) -> Any: + """should always be a simple call to `_sync_GUI_to_state` and then returning + the module's state + + Returns + ------- + Any + state of the respective module + """ + ... + + +class Module(WrapperModule, Protocol): + def validate(self) -> list[str]: + """should be a list of field names which could not be validated successfully, + propagates through: parent components must call this on all child modules and extend + their own error list with the return value + + Returns + ------- + list[str] + list of error fields (form field label) + """ + ... def save_data(self) -> None: ... @@ -268,26 +299,6 @@ class AutoFormDelete(Protocol): ) -> None: ... -@dc.dataclass(slots=True) -class AutoFormConfig: - model: type[FlatBaseModel] - data_insert: AutoFormInsert - data_update: AutoFormUpdate - data_get: AutoFormGet - data_delete: AutoFormDelete - form_fields: Sequence[FormField] - ignored_keys: Iterable[str] = tuple() - add_buttons: bool = True - - -@dc.dataclass(slots=True) -class AutoForm_State: - session: Session - cfg: AutoFormConfig - rec_id: RecId | None = None - form_data: dict[str, Any] | None = None - - class CustomWidget(QWidget): def __init__( self, @@ -337,6 +348,26 @@ class Page_InitRecCompany_State(PageState[Module]): un_id: RecId | None = None +@dc.dataclass(slots=True, kw_only=True) +class AutoFormConfig: + model: type[FlatBaseModel] + data_insert: AutoFormInsert + data_update: AutoFormUpdate + data_get: AutoFormGet + data_delete: AutoFormDelete + form_fields: Sequence[FormField] + ignored_keys: Iterable[str] = tuple() + add_buttons: bool = True + + +@dc.dataclass(slots=True, kw_only=True) +class AutoForm_State(PageState[Module]): + session: Session + cfg: AutoFormConfig + rec_id: RecId | None = None + form_data: dict[str, Any] | None = None + + @dc.dataclass(slots=True, kw_only=True) class Page_InitRecPerson_State(PageState[Module]): session: Session @@ -415,6 +446,7 @@ def _add_widget_to_layout( def build_and_add_widget( form_field: FormField[W], parent_layout: QLayout, + widget_registry: WidgetRegistry | None = None, prefix: str = "", ) -> tuple[str, W]: no_scroll_filter = NoScrollFilter(parent_layout) @@ -609,6 +641,12 @@ def build_and_add_widget( _add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only) + if widget_registry is not None: + widget_registry[full_key] = { + "widget": widget, + "form_field": form_field, + } + return full_key, cast(W, widget) @@ -642,11 +680,17 @@ def _build_ui_recursively( parent_layout.addRow(group_box) case _: - full_key, widget = build_and_add_widget(field, parent_layout, prefix=prefix) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } + full_key, _ = build_and_add_widget( + field, + parent_layout, + widget_registry=widget_registry, + prefix=prefix, + ) + # TODO remove + # widget_registry[full_key] = { + # "widget": widget, + # "form_field": field, + # } keys.append(full_key) return keys @@ -1010,6 +1054,7 @@ def validate_form_data( for key, registry_entry in widget_registry.items(): error_post: bool = False + custom_formatting: bool = False widget = registry_entry["widget"] form_field = registry_entry["form_field"] @@ -1041,16 +1086,10 @@ def validate_form_data( if widget.currentData() is not None: continue error_post = True - elif isinstance(widget, FlexibleDateInput): - date = widget.get_date() - if date and date.isValid(): - continue - error_post = True - elif isinstance(widget, FlexibleDateTimeInput): - datetime = widget.get_datetime() - if datetime and datetime.isValid(): - continue - error_post = True + elif isinstance(widget, (FlexibleDateInput, FlexibleDateTimeInput)): + valid = widget.validate() + error_post = not valid + custom_formatting = True elif isinstance( widget, ( @@ -1078,12 +1117,21 @@ def validate_form_data( error = error.replace("&&", "&") errors.append(error) # optical feedback to highlight erroneous cells - widget.setStyleSheet(""" - border: 1px solid #ef4444; - background-color: #ffe9e9; - padding: 4px; - border-radius: 4px; - """) + if not custom_formatting: + widget.setStyleSheet(""" + border: 1px solid #ef4444; + background-color: #ffe9e9; + padding: 4px; + border-radius: 4px; + """) + + return errors + + +def validate_child_modules(modules: Iterable[Module]) -> list[str]: + errors: list[str] = [] + for mod in modules: + errors.extend(mod.validate()) return errors @@ -1487,7 +1535,7 @@ class AutoForm(QWidget): self.reset_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - self.reset_btn.clicked.connect(self._reset_form) + self.reset_btn.clicked.connect(self.reset_form) self.layout_btn.addWidget(self.reset_btn) # delete self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") @@ -1527,7 +1575,7 @@ class AutoForm(QWidget): QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) self.save_btn.setText(self.save_btn_txt_enabled) - def _activation_delete(self) -> None: + def _activate_delete(self) -> None: if self.STATE.rec_id is not None: self.delete_btn.setEnabled(True) else: @@ -1537,7 +1585,7 @@ class AutoForm(QWidget): assert self.STATE.rec_id is not None, "deletion initialised despite no index set" self.cfg.data_delete(self.STATE.rec_id) self.update_triggered.emit() - self._reset_form() + self.reset_form() def _load_data( self, @@ -1550,7 +1598,7 @@ class AutoForm(QWidget): if lookup_id is None: # only debugging lookup_id = self.STATE.rec_id - self._reset_form() + self.reset_form() logger_auto_form.debug("Lookup ID: %d", lookup_id) @@ -1572,15 +1620,12 @@ class AutoForm(QWidget): logger_auto_form.debug("Form data:\n%s", pformat(form_data)) self._set_form_data(form_data) self.STATE.rec_id = lookup_id - self._activation_delete() + self._activate_delete() - def _validate_form_data(self) -> list[str]: - return validate_form_data(self.widget_registry) - - def _reset_form(self) -> None: + def reset_form(self) -> None: reset_form(self.widget_registry) self.STATE.rec_id = None - self._activation_delete() + self._activate_delete() def _get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) @@ -1602,32 +1647,32 @@ class AutoForm(QWidget): set_form_data(self.widget_registry, data) - def sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: self._load_data(self.STATE.rec_id) - def __sync_GUI_to_state(self) -> None: - form_data = self._get_form_data() - self.STATE.form_data = form_data + def _sync_GUI_to_state(self) -> None: + self.STATE.form_data = self._get_form_data() - def validate(self) -> None: ... + def validate(self) -> list[str]: + return validate_form_data(self.widget_registry) def load_state( self, new_state: AutoForm_State, ) -> None: - load_state(self.STATE, new_state) + set_page_state(self.STATE, new_state) # self.STATE.rec_id = new_state.rec_id # self.STATE.form_data = new_state.form_data - self.sync_state_to_GUI() + self._sync_state_to_GUI() def get_state(self) -> AutoForm_State: - self.__sync_GUI_to_state() + self._sync_GUI_to_state() return self.STATE def save_data(self) -> None: self._disable_save() - errors = self._validate_form_data() + errors = self.validate() if errors: # errors: abort saving and show pop up window @@ -1696,7 +1741,7 @@ class AutoForm(QWidget): logger_auto_form.info("Data saved successfully") self.update_triggered.emit() - self._activation_delete() + self._activate_delete() finally: # always re-enable save, even if error occurred self._enable_save() @@ -1740,7 +1785,7 @@ class FlexibleDateInput(QWidget): self.button.clicked.connect(self.show_calendar) self.calendar.activated.connect(self.set_date) # signal to validate - self.line_edit.editingFinished.connect(self.validate_input) + self.line_edit.editingFinished.connect(self._validate_input) self.line_edit.installEventFilter(self) self.setReadOnly(False) @@ -1754,10 +1799,10 @@ class FlexibleDateInput(QWidget): if watched == self.line_edit: if event.type() == QEvent.Type.KeyPress: if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore - self.validate_input() + self._validate_input() return True # mark event as processed elif event.type() == QEvent.Type.FocusOut: - self.validate_input() + self._validate_input() # no return to not block focus change return super().eventFilter(watched, event) @@ -1798,7 +1843,7 @@ class FlexibleDateInput(QWidget): self.line_edit.setInputMask("99.99.9999;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ") - def validate_input(self) -> None: + def _validate_input(self) -> None: logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation") if self._read_only: return @@ -1824,6 +1869,16 @@ class FlexibleDateInput(QWidget): else: self.line_edit.setStyleSheet("") + def validate(self) -> bool: + logger_custom_fields.debug("[CustomField - FlexDate]: Call validate...") + self._validate_input() + date = self.get_date() + if date and date.isValid(): + logger_custom_fields.debug("[CustomField - FlexDate]: returning valid") + return True + logger_custom_fields.debug("[CustomField - FlexDate]: returning invalid") + return False + def set_date( self, date: QDate, @@ -1905,7 +1960,7 @@ class FlexibleDateTimeInput(QWidget): self.calendar.activated.connect(self.set_date_from_calendar) # signal to validate - self.line_edit.editingFinished.connect(self.validate_input) + self.line_edit.editingFinished.connect(self._validate_input) self.line_edit.installEventFilter(self) self.setReadOnly(False) @@ -1919,10 +1974,10 @@ class FlexibleDateTimeInput(QWidget): if watched == self.line_edit: if event.type() == QEvent.Type.KeyPress: if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore - self.validate_input() + self._validate_input() return True # mark event as processed elif event.type() == QEvent.Type.FocusOut: - self.validate_input() + self._validate_input() # no return to not block focus change return super().eventFilter(watched, event) @@ -1963,7 +2018,7 @@ class FlexibleDateTimeInput(QWidget): self.line_edit.setInputMask("99.99.9999 99:99;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") - def validate_input(self) -> None: + def _validate_input(self) -> None: logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation") if self._read_only: return @@ -1990,6 +2045,16 @@ class FlexibleDateTimeInput(QWidget): else: self.line_edit.setStyleSheet("") + def validate(self) -> bool: + logger_custom_fields.debug("[CustomField - FlexDatetime]: Call validate...") + self._validate_input() + datetime = self.get_datetime() + if datetime and datetime.isValid(): + logger_custom_fields.debug("[CustomField - FlexDatetime]: returning valid") + return True + logger_custom_fields.debug("[CustomField - FlexDatetime]: returning invalid") + return False + def set_date_from_calendar( self, date: QDate, @@ -2692,10 +2757,10 @@ class Page_NewInitRec(QWidget): logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) self.person_requested.emit(req_state) - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: pass - def __sync_GUI_to_state(self) -> None: + def _sync_GUI_to_state(self) -> None: pass def validate(self) -> None: @@ -2705,10 +2770,10 @@ class Page_NewInitRec(QWidget): self, new_state: Page_NewInitRec_State, ) -> None: - self.__sync_state_to_GUI() + self._sync_state_to_GUI() def get_state(self) -> Page_NewInitRec_State: - self.__sync_GUI_to_state() + self._sync_GUI_to_state() return self.STATE def save_data(self) -> None: @@ -2900,10 +2965,10 @@ class Page_InitRecCompany(QWidget): logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) self.company_profile_requested.emit(req_state) - def reset_form(self) -> None: - self.auto_form._reset_form() + def reset(self) -> None: + self.auto_form.reset_form() - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: auto_form_state = AutoForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, @@ -2912,26 +2977,22 @@ class Page_InitRecCompany(QWidget): ) self.auto_form.load_state(auto_form_state) - def __sync_GUI_to_state(self) -> None: - # TODO behaviour not consistent - self.auto_form.__sync_GUI_to_state() - - def validate(self) -> None: ... + def _sync_GUI_to_state(self) -> None: + auto_form_state = self.auto_form.get_state() + self.STATE.un_id = auto_form_state.rec_id def load_state( self, new_state: Page_InitRecCompany_State, ) -> None: - self.STATE.un_id = new_state.un_id + set_page_state(self.STATE, new_state) - self.__sync_state_to_GUI() + self._sync_state_to_GUI() def get_state(self) -> Page_InitRecCompany_State: - self.__sync_GUI_to_state() - return self.STATE + self._sync_GUI_to_state() - def save_data(self) -> None: - self.auto_form.save_data() + return self.STATE class Page_InitRecPerson(QWidget): @@ -3067,9 +3128,9 @@ class Page_InitRecPerson(QWidget): self.selection_county.update() def reset_form(self) -> None: - self.auto_form._reset_form() + self.auto_form.reset_form() - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: auto_form_state = AutoForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, @@ -3078,25 +3139,21 @@ class Page_InitRecPerson(QWidget): ) self.auto_form.load_state(auto_form_state) - def __sync_GUI_to_state(self) -> None: - self.auto_form.__sync_GUI_to_state() - - def validate(self) -> None: ... + def _sync_GUI_to_state(self) -> None: + auto_form_state = self.auto_form.get_state() + self.STATE.pers_id = auto_form_state.rec_id def load_state( self, new_state: Page_InitRecPerson_State, ) -> None: - self.STATE.pers_id = new_state.pers_id - - self.__sync_state_to_GUI() + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI() def get_state(self) -> Page_InitRecPerson_State: - self.__sync_GUI_to_state() - return self.STATE + self._sync_GUI_to_state() - def save_data(self) -> None: - self.auto_form.save_data() + return self.STATE class Page_CompanyProfile(QWidget): @@ -3352,7 +3409,7 @@ class Page_CompanyProfile(QWidget): self.add_btn_pauschal.setEnabled(True) self.add_btn_individual.setEnabled(True) - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: if self.STATE.rec_id is None: # just clear and do nothing else logger_gui.debug("just reset, ID is None") @@ -3376,7 +3433,7 @@ class Page_CompanyProfile(QWidget): self.table_master_data.load_data(data) self.set_company_name(data["Name:"]) - def __sync_GUI_to_state(self) -> None: ... + def _sync_GUI_to_state(self) -> None: ... def validate(self) -> None: ... @@ -3387,7 +3444,7 @@ class Page_CompanyProfile(QWidget): logger_gui.debug("Called function to load with ID: %s", new_state.rec_id) self.STATE.rec_id = new_state.rec_id - self.__sync_state_to_GUI() + self._sync_state_to_GUI() def get_state(self) -> Any: ... @@ -3715,9 +3772,14 @@ class Page_Consulting(QWidget): # layout_change_title.addWidget(change_title_btn) # change_title_btn.clicked.connect(self.set_company_name) # container_layout.addLayout(layout_change_title) - btn_get_data_table = QPushButton("Call 'get_data' from table") - btn_get_data_table.clicked.connect(self._debug_get_data_table) + btn_get_data_table = QPushButton("Debug call 'get_state' from table and page") + btn_get_data_table.clicked.connect(self._debug_get_state) container_layout.addWidget(btn_get_data_table) + btn_validate_data_table = QPushButton( + "Debuig call 'validate' from table and page" + ) + btn_validate_data_table.clicked.connect(self._debug_validate) + container_layout.addWidget(btn_validate_data_table) separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) @@ -3747,24 +3809,6 @@ class Page_Consulting(QWidget): self.table_sessions = Page_Consulting_Table(site_state=self.STATE, state=table_state) self.STATE.child_modules.append(self.table_sessions) container_layout.addWidget(self.table_sessions) - # container_layout.addSpacing(50) - - # self.layout_sessions = QVBoxLayout() - # self.layout_sessions.setContentsMargins(0, 0, 0, 0) - # container_layout.addLayout(self.layout_sessions) - # self.btn_add_cons_session = QPushButton("+ Hinzufügen") - # self.btn_add_cons_session.setStyleSheet( - # "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" - # ) - # self.btn_add_cons_session.clicked.connect(self.add_cons_entry) - # self.layout_sessions.addWidget(self.btn_add_cons_session) - # self.layout_sessions.addStretch(1) - - # self.cons_entries: list[QWidget] = [] - - # self.layout_sessions.addStretch() - - # container_layout.addStretch() # --- STYLE --- self.setStyleSheet(""" @@ -3798,70 +3842,6 @@ class Page_Consulting(QWidget): # } # """) - # def add_cons_entry(self) -> None: - # logger_gui.debug("[Consulting Page] Adding entry...") - - # row_widget = QWidget() - # entry_row = QHBoxLayout(row_widget) - # entry_fields = QHBoxLayout() - # entry_row.addLayout(entry_fields) - - # txt1 = QLineEdit() - # txt2 = QPlainTextEdit() - # txt2.setMaximumHeight(100) - # entry_fields.addWidget(txt1) - # entry_fields.addWidget(txt2) - - # del_btn = QPushButton("🗑️") - # del_btn.setFixedSize(30, 30) - # # Lambda with default parameter to delete exactly this(!) box - # del_btn.clicked.connect( - # lambda checked=False, widget=row_widget: self.remove_cons_entry(widget) - # ) - # entry_row.addWidget(del_btn) - - # row_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - # # row_widget.setMaximumHeight(200) - - # self.cons_entries.append(row_widget) - # self.layout_sessions.insertWidget(self.layout_sessions.count() - 2, row_widget) - - # def remove_cons_entry( - # self, - # row_widget: QWidget, - # ) -> None: - # logger_gui.debug("[Consulting Page]: Delete clicked...") - # self.layout_sessions.removeWidget(row_widget) - # row_widget.deleteLater() - # self.cons_entries.remove(row_widget) - - def _debug_get_data_table(self) -> None: - self.table_sessions.get_state() - - # TODO placeholder, check if needed - def __sync_state_to_GUI(self) -> None: - match self.STATE.cons_type: - case ConsultingType.PAUSCHAL: - self.type_pauschal_btn.setChecked(True) - case ConsultingType.INDIVIUAL: - self.type_individual_btn.setChecked(True) - - # deactivate if at least one ID is set (type is pre-defined by how - # the site is called) - if self.STATE.un_id or self.STATE.pers_id: - self.type_pauschal_btn.setEnabled(False) - self.type_individual_btn.setEnabled(False) - - # call to child modules not needed because these are initialised by their own - # "load_state" method which again calls the modules sync method - - def __sync_GUI_to_state(self) -> None: - # call to child modules not needed because their syncing method is called when - # the "get_state" method is called --> this has to be managed within this method - - table_state = self.table_sessions.get_state() - self.STATE.cons_sessions = table_state.row_states - def type_consulting_changed( self, button_id: int, @@ -3879,21 +3859,68 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) self.STATE.cons_type = cons_type - def load_from_state( + def _debug_get_state(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call get state on table...") + _ = self.table_sessions.get_state() + logger_page_consulting.debug("[Consulting Page] Call get state on page...") + state = self.get_state() + logger_page_consulting.debug("[Consulting Page] Page state:\n%s", state) + + def _debug_validate(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call validate data on table...") + errors = self.table_sessions.validate() + logger_page_consulting.debug("[Consulting Page] Table errors:\n%s", pformat(errors)) + logger_page_consulting.debug("[Consulting Page] Call get state on page...") + errors = self.validate() + logger_page_consulting.debug("[Consulting Page] Page errors:\n%s", pformat(errors)) + + def _sync_state_to_GUI(self) -> None: + match self.STATE.cons_type: + case ConsultingType.PAUSCHAL: + self.type_pauschal_btn.setChecked(True) + case ConsultingType.INDIVIUAL: + self.type_individual_btn.setChecked(True) + + # deactivate if at least one ID is set (type is pre-defined by how + # the site is called) + if self.STATE.un_id or self.STATE.pers_id: + self.type_pauschal_btn.setEnabled(False) + self.type_individual_btn.setEnabled(False) + + # call to child modules' syncing method not needed because these are initialised by + # their own "load_state" method which again calls the modules sync method + + def _sync_GUI_to_state(self) -> None: + # call to child modules' syncing method not needed because their syncing method is + # called when the "get_state" method is called --> this has to be managed within + # this method + table_state = self.table_sessions.get_state() + self.STATE.cons_sessions = table_state.row_states + + def validate(self) -> list[str]: + errors: list[str] = [] + errors.extend(self.table_sessions.validate()) + + return errors + + def load_state( self, new_state: Page_Consulting_State, ) -> None: # TODO add correct loading behaviour # TODO add validation (correct type with necessary information) logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) - load_state(self.STATE, new_state) - # self.STATE.un_id = new_state.un_id - # self.STATE.pers_id = new_state.pers_id - # self.STATE.cons_type = new_state.cons_type + set_page_state(self.STATE, new_state) + # child modules table_state = Page_Consulting_Table_State(row_states=self.STATE.cons_sessions) self.table_sessions.load_state(table_state) - self.__sync_state_to_GUI() + self._sync_state_to_GUI() + + def get_state(self) -> Page_Consulting_State: + self._sync_GUI_to_state() + + return self.STATE class Page_Consulting_ConsultingSession(QWidget): @@ -3918,40 +3945,91 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) ROW_HEIGHT = 70 - # ?? widget registry? + self.widget_registry: WidgetRegistry = {} user_field_def = FormField[QLineEdit]( - "", + "Nutzername", FormFieldType.TEXT, readonly=True, placeholder="wird über aktiven Nutzer bei Anlage gesetzt", + enable_uuid_key=True, ) _, self.user_field = build_and_add_widget(user_field_def, layout) # ?? date or datetime? - timestamp_def = FormField[FlexibleDateTimeInput]("", FormFieldType.DATETIME_FLEXIBLE) - _, self.timestamp = build_and_add_widget(timestamp_def, layout) + timestamp_def = FormField[FlexibleDateTimeInput]( + "Zeitstempel", + FormFieldType.DATETIME_FLEXIBLE, + required=True, + enable_uuid_key=True, + ) + _, self.timestamp = build_and_add_widget( + timestamp_def, + layout, + widget_registry=self.widget_registry, + ) self.timestamp.set_pydatetime(datetime.datetime.now()) - contact_def = FormField[QLineEdit]("", FormFieldType.TEXT, placeholder="Freitext") - _, self.contact = build_and_add_widget(contact_def, layout) + contact_def = FormField[QLineEdit]( + "Kontaktperson", + FormFieldType.TEXT, + placeholder="Freitext", + required=True, + enable_uuid_key=True, + ) + _, self.contact = build_and_add_widget( + contact_def, + layout, + widget_registry=self.widget_registry, + ) contact_type_def = FormField[QComboBox]( - "", + "Kontakttyp", FormFieldType.DROPDOWN, - placeholder="sollte Dropdown sein", options=[("Persönlich", None), ("Telefon", None), ("Mail", None)], + required=True, + enable_uuid_key=True, + ) + _, self.contact_type = build_and_add_widget( + contact_type_def, + layout, + widget_registry=self.widget_registry, ) - _, self.contact_type = build_and_add_widget(contact_type_def, layout) - crm_link_def = FormField[QLineEdit]("", FormFieldType.TEXT, placeholder="CRM Links") - _, self.crm_link = build_and_add_widget(crm_link_def, layout) + crm_link_def = FormField[QLineEdit]( + "CRM-Verknüpfung", + FormFieldType.TEXT, + placeholder="CRM Links", + required=True, + enable_uuid_key=True, + ) + _, self.crm_link = build_and_add_widget( + crm_link_def, + layout, + widget_registry=self.widget_registry, + ) - plain_text_def = FormField[QPlainTextEdit]("", FormFieldType.LONGTEXT) - _, self.plain_text = build_and_add_widget(plain_text_def, layout) + plain_text_def = FormField[QPlainTextEdit]( + "Anmerkungen", + FormFieldType.LONGTEXT, + enable_uuid_key=True, + ) + _, self.plain_text = build_and_add_widget( + plain_text_def, + layout, + widget_registry=self.widget_registry, + ) - response_def = FormField[QPlainTextEdit]("", FormFieldType.LONGTEXT) - _, self.response = build_and_add_widget(response_def, layout) + response_def = FormField[QPlainTextEdit]( + "Rückmeldung", + FormFieldType.LONGTEXT, + enable_uuid_key=True, + ) + _, self.response = build_and_add_widget( + response_def, + layout, + widget_registry=self.widget_registry, + ) self.btn_delete = QPushButton("✕") self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen @@ -3991,9 +4069,9 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - self.__sync_state_to_GUI() # ?? only needed if entry is from database? + self._sync_state_to_GUI() # ?? only needed if entry is from database? - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) @@ -4005,7 +4083,7 @@ class Page_Consulting_ConsultingSession(QWidget): set_widget_value(self.plain_text, self.STATE.anmerkungen) set_widget_value(self.response, self.STATE.rueckmeldung) - def __sync_GUI_to_state(self) -> None: + def _sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() self.STATE.ansprechpartner = get_widget_value(self.contact) self.STATE.interaction_scheme = get_widget_value(self.contact_type) @@ -4014,12 +4092,16 @@ class Page_Consulting_ConsultingSession(QWidget): self.STATE.rueckmeldung = get_widget_value(self.response) def get_state(self) -> Page_Consulting_ConsultingSession_State: - self.__sync_GUI_to_state() + self._sync_GUI_to_state() return self.STATE - def validate(self) -> None: - pass + def validate(self) -> list[str]: + logger_page_consulting.debug( + ">>>>> Call validate on Session. Widget registry:\n%s", + pprint_registry(self.widget_registry), + ) + return validate_form_data(self.widget_registry) def save_data(self) -> None: pass @@ -4028,13 +4110,13 @@ class Page_Consulting_ConsultingSession(QWidget): self, new_state: Page_Consulting_ConsultingSession_State, ) -> None: - load_state(self.STATE, new_state) - self.__sync_state_to_GUI() + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI() -def load_state( - target: T, - source: T, +def set_page_state( + target: S, + source: S, deep: bool = False, ) -> None: """ @@ -4047,6 +4129,9 @@ def load_state( raise TypeError("Both source and target must have the same type") for field in dc.fields(source): + if field.name == "child_modules": + continue + new_value = getattr(source, field.name) if deep: @@ -4248,12 +4333,12 @@ class Page_Consulting_Table(QWidget): while self.STATE.child_modules: self.remove_row(self.STATE.child_modules[0]) - def __sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self) -> None: self.reset() for row_state in self.STATE.row_states: self.add_row(row_state) - def __sync_GUI_to_state(self) -> None: + def _sync_GUI_to_state(self) -> None: logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") row_data: list[Page_Consulting_ConsultingSession_State] = [] @@ -4281,7 +4366,7 @@ class Page_Consulting_Table(QWidget): def get_state(self) -> Page_Consulting_Table_State: logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") - self.__sync_GUI_to_state() + self._sync_GUI_to_state() logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) @@ -4294,12 +4379,16 @@ class Page_Consulting_Table(QWidget): logger_page_consulting.debug( "[Page -- Consulting] Table: Call loading from state, %s", new_state ) - self.STATE.row_states = new_state.row_states + set_page_state(self.STATE, new_state) - self.__sync_state_to_GUI() + self._sync_state_to_GUI() - def validate(self) -> None: - pass + def validate(self) -> list[str]: + errors: list[str] = [] + for m in self.STATE.child_modules: + errors.extend(m.validate()) + + return errors def save_data(self) -> None: pass @@ -4578,7 +4667,7 @@ class MainWindow(QMainWindow): self, new_state: Page_Consulting_State, ) -> None: - self.consulting_page.load_from_state(new_state) + self.consulting_page.load_state(new_state) self._navigate_to_target(self.consulting_page) # --- MENÜ LOGIK --- @@ -4632,6 +4721,15 @@ def qt_message_handler(mode, context, message): logger_gui.critical(message) +if TYPE_CHECKING: + # TODO add all relevant modules + # enable static type checking for the protocol + _m1: type[Module] = Page_Consulting + _m2: type[Module] = Page_Consulting_Table + _m3: type[Module] = Page_Consulting_ConsultingSession + _w1: type[WrapperModule] = Page_InitRecCompany + _w2: type[WrapperModule] = Page_InitRecPerson + if __name__ == "__main__": sys.excepthook = global_exception_handler qInstallMessageHandler(qt_message_handler) diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index 9d0cfd7..db7b996 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -37,6 +37,9 @@ logger_get_data.setLevel(logging.DEBUG) logger_auto_form = logger_gui.getChild("get_data_auto_form") logger_auto_form.setLevel(logging.DEBUG) +logger_custom_fields = logger_gui.getChild("custom_fields") +logger_custom_fields.setLevel(logging.DEBUG) + logger_page_consulting = logger_gui.getChild("page_consulting") logger_page_consulting.setLevel(logging.DEBUG) -- 2.34.1 From 9cfe53509fd4e5c46f6464dd98fd2491260f22d8 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 9 Jul 2026 13:27:52 +0200 Subject: [PATCH 21/74] refactoring --- src/wce_crm/_remove.py | 26 +++ src/wce_crm/backend/backend.py | 6 +- src/wce_crm/data_models.py | 37 ++++ src/wce_crm/form_defs.py | 2 + src/wce_crm/gui.py | 378 +++++++++++++++++++-------------- 5 files changed, 292 insertions(+), 157 deletions(-) create mode 100644 src/wce_crm/_remove.py diff --git a/src/wce_crm/_remove.py b/src/wce_crm/_remove.py new file mode 100644 index 0000000..523c3e1 --- /dev/null +++ b/src/wce_crm/_remove.py @@ -0,0 +1,26 @@ +def _get_leafs(data): + if isinstance(data, dict): + for value in data.values(): + yield from _get_leafs(value) + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from _get_leafs(item) + else: + yield data + + +def _get_leaf_dicts(data): + if isinstance(data, dict): + has_inner_dicts = False + + for value in data.values(): + for inner_dict in _get_leaf_dicts(value): + has_inner_dicts = True + yield inner_dict + + if not has_inner_dicts: + yield data + + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from _get_leaf_dicts(item) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index d382a6b..6c5dead 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -147,7 +147,7 @@ def initrec_company_get_initial_recording( row = ret.fetchone() assert row, "row was not obtained" - return row._asdict() + return row._asdict() # type: ignore def initrec_company_delete_initial_recording( @@ -196,7 +196,7 @@ def initrec_person_update_initial_recording( def initrec_person_get_initial_recording( id_: RecId, ) -> dict[str, Any]: - logger.debug("[Call backend] get_initial_recording") + logger.debug("[Call backend] get_initial_recording person") stmt = db.grunderfassung_personen.select().where( db.grunderfassung_personen.c.pers_id == id_ ) @@ -209,7 +209,7 @@ def initrec_person_get_initial_recording( row = ret.fetchone() assert row, "row was not obtained" - return row._asdict() + return row._asdict() # type: ignore def initrec_person_delete_initial_recording( diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 88efdbd..e5ab2b9 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -13,10 +13,47 @@ from pydantic import ( field_validator, model_validator, ) +from pydantic_core import ErrorDetails ValidAge = Annotated[int, Field(ge=0, le=99)] +# // validation translation +GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = { + "missing": "Dieses Feld ist ein Pflichtfeld.", + "int_parsing": "Bitte eine gültige Ganzzahl eingeben.", + "float_parsing": "Bitte eine gültige Dezimalzahl eingeben.", + "string_too_short": "Der Text ist zu kurz. Er muss mindestens {min_length} Zeichen lang sein.", + "string_too_long": "Der Text ist zu lang. Er darf maximal {max_length} Zeichen lang sein.", + "greater_than": "Der Wert muss größer als {gt} sein.", +} + + +def translate_pydantic_errors( + errors: list[ErrorDetails], +) -> list[ErrorDetails]: + translated_errors: list[ErrorDetails] = [] + + for error in errors: + err_type = error.get("type", None) + + if err_type is None or err_type not in GERMAN_ERROR_MESSAGES: + error["msg"] = "[fehleden Übersetzung, nur Englisch]: " + error["msg"] + translated_errors.append(error) + continue + + msg_template = GERMAN_ERROR_MESSAGES[err_type] + ctx = error.get("ctx", {}) + try: + error["msg"] = msg_template.format(**ctx) + except KeyError: + error["msg"] = msg_template + + translated_errors.append(error) + + return translated_errors + + def _parse_json(value: Any) -> str: if isinstance(value, datetime.date): return value.isoformat() diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index c50b8d8..765f6e4 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -911,6 +911,7 @@ INITREC_COMP = [ required=False, key="Metadaten_nutzer", readonly=True, + ignore_get_data=True, ), FormField( "Fallnummer", @@ -956,6 +957,7 @@ INITREC_PERSON = [ required=False, key="Metadaten_nutzer", readonly=True, + ignore_get_data=True, ), FormField( "Wiedereintrittsdatum", diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 66475df..0316cef 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -4,13 +4,11 @@ import copy import dataclasses as dc import datetime import enum -import pickle import re import sys import traceback from collections import defaultdict from collections.abc import Container, Iterable, Sequence -from pathlib import Path from pprint import pformat from typing import ( TYPE_CHECKING, @@ -26,7 +24,6 @@ from typing import ( ) from typing_extensions import override -from dopt_basics import datetime as dopt_dt from pydantic import ( ValidationError, ) @@ -36,7 +33,6 @@ from PySide6.QtCore import ( QEvent, QObject, Qt, - QTime, QTimer, QtMsgType, Signal, @@ -84,6 +80,7 @@ from wce_crm.data_models import ( FlatBaseModel, Grunderfassung_Personen, Grunderfassung_Unternehmen, + translate_pydantic_errors, ) from wce_crm.form_defs import ( INITREC_COMP, @@ -166,36 +163,6 @@ def unmerge_dict_to_list( return [dict(zip(keys, row)) for row in zip(*value_lists)] -# TODO check removal or move to basics package -def _get_leafs(data): - if isinstance(data, dict): - for value in data.values(): - yield from _get_leafs(value) - elif isinstance(data, (list, tuple, set)): - for item in data: - yield from _get_leafs(item) - else: - yield data - - -# TODO check removal or move to basics package -def _get_leaf_dicts(data): - if isinstance(data, dict): - has_inner_dicts = False - - for value in data.values(): - for inner_dict in _get_leaf_dicts(value): - has_inner_dicts = True - yield inner_dict - - if not has_inner_dicts: - yield data - - elif isinstance(data, (list, tuple, set)): - for item in data: - yield from _get_leaf_dicts(item) - - def pprint_registry(widget_registry: WidgetRegistry) -> None: print("\n\n>>> Widget registry:") for key, entry in widget_registry.items(): @@ -665,8 +632,6 @@ def _build_ui_recursively( else: full_key = field.key - widget: QWidget - match field.type: case FormFieldType.GROUP: group_box = QGroupBox(field.label) @@ -686,11 +651,6 @@ def _build_ui_recursively( widget_registry=widget_registry, prefix=prefix, ) - # TODO remove - # widget_registry[full_key] = { - # "widget": widget, - # "form_field": field, - # } keys.append(full_key) return keys @@ -722,7 +682,7 @@ def reset_form( form_field = registry_entry["form_field"] # TODO check if this behaviour is correct in other contexts, deactivated because of - # TODO company search widget + # company search widget # if form_field.readonly: # continue @@ -1410,6 +1370,7 @@ class AutoForm(QWidget): ) -> None: super().__init__() self.STATE = state + self.SESSION_USER_KEY: Final[str] = "Metadaten_nutzer" self.cfg = state.cfg self.setStyleSheet(""" QGroupBox { @@ -1515,40 +1476,21 @@ class AutoForm(QWidget): self.debug_form_data: dict[str, Any] = {} if self.add_buttons: - self.layout_btn = QHBoxLayout() - self.main_layout.addLayout(self.layout_btn) - # save - self.save_btn_txt_enabled = "Speichern (Strg + S)" - self.save_btn_txt_disabled = "Wird gespeichert..." - self.save_btn = QPushButton(self.save_btn_txt_enabled) - self.save_btn.setShortcut("Ctrl+S") - self.save_btn.setFixedHeight(50) - self.save_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) + self.edit_buttons = EditButtons(add_reset_btn=True) + reset_btn = self.edit_buttons.reset_btn + assert reset_btn + self.save_btn = self.edit_buttons.save_btn + self.reset_btn = reset_btn + self.delete_btn = self.edit_buttons.delete_btn + self.save_btn.clicked.connect(self.save_data) - self.layout_btn.addWidget(self.save_btn) - # reset - self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") - self.reset_btn.setShortcut("Ctrl+Z") - self.reset_btn.setFixedHeight(50) - self.reset_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) self.reset_btn.clicked.connect(self.reset_form) - self.layout_btn.addWidget(self.reset_btn) - # delete - self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") - self.delete_btn.setShortcut("Ctrl+L") - self.delete_btn.setFixedHeight(50) - self.delete_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) self.delete_btn.clicked.connect(self._delete_data) - self.layout_btn.addWidget(self.delete_btn) + + self.main_layout.addWidget(self.edit_buttons) logger_auto_form.debug( - "Initialised Auto Form Widget. Registry:%s", + "[Auto-Form] Initialised Auto Form Widget. Registry:%s", pformat_registry(self.widget_registry), ) @@ -1561,11 +1503,11 @@ class AutoForm(QWidget): except ValueError: index = None self.STATE.rec_id = index - logger_gui.debug("Set index to %d, new state: %s", index, self.STATE) + logger_gui.debug("[Auto-Form] Set index to %s, new state: %s", index, self.STATE) def _disable_save(self) -> None: self.save_btn.setEnabled(False) - self.save_btn.setText(self.save_btn_txt_disabled) + self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) def _enable_save( self, @@ -1573,7 +1515,7 @@ class AutoForm(QWidget): ) -> None: QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) - self.save_btn.setText(self.save_btn_txt_enabled) + self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) def _activate_delete(self) -> None: if self.STATE.rec_id is not None: @@ -1589,35 +1531,32 @@ class AutoForm(QWidget): def _load_data( self, - lookup_id: int | None = None, + lookup_id: int | None, ) -> None: if DEBUG_NO_DATABASE: return - logger_auto_form.info(">>>> LOAD CLICKED") - if lookup_id is None: - # only debugging - lookup_id = self.STATE.rec_id + logger_auto_form.info("[Auto-Form] Load data method...") self.reset_form() - logger_auto_form.debug("Lookup ID: %d", lookup_id) + logger_auto_form.debug("[Auto-Form] Lookup ID: %s", lookup_id) - if lookup_id is not None and lookup_id > 0: + if lookup_id is not None: logger_auto_form.debug("Load from DB:") loaded_data = self.cfg.data_get(lookup_id) else: - logger_auto_form.error("Loading: Lookup ID 0 or NULL. Do nothing!") + logger_auto_form.debug("[Auto-Form] Loading: Lookup ID NULL. Do nothing!") return logger_auto_form.debug( - "Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) ) model = self.cfg.model(**loaded_data) - logger_auto_form.debug("Loaded to Pydantic.") - logger_auto_form.debug("Convert to GUI structure...") + logger_auto_form.debug("[Auto-Form] Loaded to Pydantic.") + logger_auto_form.debug("[Auto-Form] Convert to GUI structure...") form_data = model.to_gui() - logger_auto_form.debug("Set form data...") - logger_auto_form.debug("Form data:\n%s", pformat(form_data)) + logger_auto_form.debug("[Auto-Form] Set form data...") + logger_auto_form.debug("[Auto-Form] Form data:\n%s", pformat(form_data)) self._set_form_data(form_data) self.STATE.rec_id = lookup_id self._activate_delete() @@ -1625,12 +1564,13 @@ class AutoForm(QWidget): def reset_form(self) -> None: reset_form(self.widget_registry) self.STATE.rec_id = None + self.STATE.form_data = None self._activate_delete() def _get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call get form data:") - logger_auto_form.debug("Form Data:\n%s", pformat(form_data)) + logger_auto_form.debug("[Auto-Form] Call get form data:") + logger_auto_form.debug("[Auto-Form] Form Data:\n%s", pformat(form_data)) if DEBUG_GET_SET: self.debug_form_data = form_data @@ -1641,7 +1581,7 @@ class AutoForm(QWidget): self, data: dict[str, Any], ) -> None: - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call set form data:") + logger_auto_form.debug("[Auto-Form] Call set form data...") if DEBUG_GET_SET: data = self.debug_form_data @@ -1651,7 +1591,10 @@ class AutoForm(QWidget): self._load_data(self.STATE.rec_id) def _sync_GUI_to_state(self) -> None: - self.STATE.form_data = self._get_form_data() + form_data = self._get_form_data() + # TODO change usage of user ID (needs changes in model and backend) + form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name + self.STATE.form_data = form_data def validate(self) -> list[str]: return validate_form_data(self.widget_registry) @@ -1661,8 +1604,6 @@ class AutoForm(QWidget): new_state: AutoForm_State, ) -> None: set_page_state(self.STATE, new_state) - # self.STATE.rec_id = new_state.rec_id - # self.STATE.form_data = new_state.form_data self._sync_state_to_GUI() def get_state(self) -> AutoForm_State: @@ -1675,50 +1616,29 @@ class AutoForm(QWidget): errors = self.validate() if errors: - # errors: abort saving and show pop up window - error_text = ( - "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " - + "\n▸ ".join(errors) + logger_auto_form.error( + "[Auto-Form] Error during GUI validation phase:\n%s", pformat(errors) ) - QMessageBox.warning(self, "Fehlende Angaben", error_text) + GUI_validation_error_handling(errors) self._enable_save() return - # no errors: data can be saved - logger_auto_form.info("Erfolg! Alle Daten sind valide.") - logger_auto_form.info("Get form data call...") - form_data = self._get_form_data() + logger_auto_form.info("[Auto-Form] Success! All data is valid") + logger_auto_form.info("[Auto-Form] Get form data call...") + state = self.get_state() + form_data = state.form_data + assert form_data, "form data not set" - logger_auto_form.debug( - "\n\n------------>>>>>>>>> Get form data\n%s", pformat(form_data) - ) + logger_auto_form.debug("[Auto-Form] Get form data\n%s", pformat(form_data)) try: - logger_auto_form.debug("------------>>>>>>>>> Call Pydantic") + logger_auto_form.debug("[Auto-Form] Call Pydantic...") validated_data = self.cfg.model(**form_data) - # # TODO add user? except ValidationError as e: - # catch errors and show them in GUI - error_texts = [] - - # Pydantic detailed error list - for error in e.errors(): - logger_gui.error("Error during validation phase:\n%s", pformat(error)) - - error_field = str(error["loc"][0]) - reason = error["msg"] - - error_texts.append(f"- {error_field}: {reason}") - - # TODO: Is this needed and feasible? - # formatting red - # if fehlerhaftes_feld in self.widgets: - # self.widgets[fehlerhaftes_feld].setStyleSheet( - # "border: 1px solid red; background: #fef2f2;" - # ) - - # tell user what went wrong - QMessageBox.warning(self, "Eingabefehler", "\n".join(error_texts)) + logger_auto_form.error( + "[Auto-Form] Error during Pydantic validation phase:\n%s", pformat(e.errors()) + ) + pydantic_validation_error_handling(e) else: if DEBUG_NO_DATABASE: return @@ -1726,20 +1646,20 @@ class AutoForm(QWidget): # save data to database db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) logger_auto_form.debug( - ("Form data with 'exlude' (must be saved in the database):\n%s"), + "[Auto-Form] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), ) if self.STATE.rec_id is None: - logger_auto_form.debug("Insert triggered") + logger_auto_form.debug("[Auto-Form] Insert triggered...") init_rec_id = self.cfg.data_insert(db_data) assert isinstance(init_rec_id, int) self.STATE.rec_id = init_rec_id else: - logger_auto_form.debug("Update triggered") + logger_auto_form.debug("[Auto-Form] Update triggered...") self.cfg.data_update(self.STATE.rec_id, db_data) - logger_auto_form.info("Data saved successfully") + logger_auto_form.info("[Auto-Form] Data saved successfully") self.update_triggered.emit() self._activate_delete() finally: @@ -3688,19 +3608,6 @@ class Page_Consulting(QWidget): # state management self.STATE = Page_Consulting_State(session=session) - # self.current_id: InitRecId = -1 - # self.COMPANY_FETAURES_MASTER_DATA: dict[str, str] = { - # "ma_unternehmensname": "Name:", - # "ma_branche": "Branche:", - # "ma_strasse": "Straße:", - # "ma_hausnummer": "Hausnummer:", - # "ma_plz": "PLZ:", - # "ma_ort": "Ort:", - # "ma_website": "Webseite:", - # "ma_mail": "E-Mail:", - # "ma_telefonnummer": "Telefon:", - # } - # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -3907,8 +3814,6 @@ class Page_Consulting(QWidget): self, new_state: Page_Consulting_State, ) -> None: - # TODO add correct loading behaviour - # TODO add validation (correct type with necessary information) logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) set_page_state(self.STATE, new_state) # child modules @@ -4696,19 +4601,184 @@ def global_exception_handler(exc_type, exc_value, exc_traceback): # message to user # check if QApplication exists otherwise crashing of crash handler possible) if QApplication.instance(): - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setWindowTitle("Kritischer Fehler") - msg_box.setText( - "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert." + msg_box = get_error_message_box( + QMessageBox.Icon.Critical, + "Kritischer Fehler", + "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", + error_msg, ) - # details for user or screenshots - msg_box.setDetailedText(error_msg) + # msg_box.setIcon(QMessageBox.Icon.Critical) + # msg_box.setWindowTitle("Kritischer Fehler") + # msg_box.setText( + # "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert." + # ) + # # details for user or screenshots + # msg_box.setDetailedText(error_msg) msg_box.exec() sys.exit(1) +def get_error_message_box( + msg_type: QMessageBox.Icon, + title: str, + message: str, + detailed_text: str | None = None, +) -> QMessageBox: + msg_box = QMessageBox() + msg_box.setIcon(msg_type) + msg_box.setWindowTitle(title) + msg_box.setText(message) + if detailed_text is not None: + msg_box.setDetailedText(detailed_text) + + return msg_box + + +def pydantic_validation_error_handling( + val_error: ValidationError, +) -> None: + error_texts: list[str] = [] + translated_errors = translate_pydantic_errors(val_error.errors()) + + for error in translated_errors: + error_field = str(error["loc"][0]) + reason = error["msg"] + path = " → ".join(error["loc"][:-1]) # type: ignore + + error_texts.append(f"- {error_field}: {reason}, (Pfad: {path})") + + msg_box = get_error_message_box( + QMessageBox.Icon.Warning, + "Fehler bei der Validierung der Eingabedaten", + ( + "Bei der Validierung der Eingaben ist ein Fehler aufgetreten. Details " + "sind unten angefügt." + ), + "\n".join(error_texts), + ) + msg_box.exec() + + +def GUI_validation_error_handling( + errors: list[str], +) -> None: + error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join( + errors + ) + msg_box = get_error_message_box( + QMessageBox.Icon.Warning, + "Fehlende oder fehlerhafte Angaben", + error_text, + ) + msg_box.exec() + + +# @dc.dataclass(slots=True) +# class EditButton: +# btn: QWidget +# txt_enabled: str +# txt_disabled: str + + +# @dc.dataclass(slots=True, kw_only=True) +# class EditButtonLayout: +# layout: QLayout +# save: EditButton +# delete: EditButton +# reset: EditButton | None + + +class EditButtons(QWidget): + def __init__( + self, + add_reset_btn: bool, + ) -> None: + super().__init__() + + layout_btn = QHBoxLayout(self) + layout_btn.setContentsMargins(0, 0, 0, 0) + + # main_layout.addLayout(layout_btn) # TODO add in component + # save + self.save_btn_txt_enabled = "Speichern (Strg + S)" + self.save_btn_txt_disabled = "Wird gespeichert..." + self.save_btn = QPushButton(self.save_btn_txt_enabled) + self.save_btn.setShortcut("Ctrl+S") + self.save_btn.setFixedHeight(50) + self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + # save_btn.clicked.connect(save_data) # TODO add in component + layout_btn.addWidget(self.save_btn) + + # reset + self.reset_button: QPushButton | None = None + if add_reset_btn: + self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") + self.reset_btn.setShortcut("Ctrl+Z") + self.reset_btn.setFixedHeight(50) + self.reset_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + # reset_btn.clicked.connect(reset_form) # TODO add in component + layout_btn.addWidget(self.reset_btn) + + # delete + self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") + self.delete_btn.setShortcut("Ctrl+L") + self.delete_btn.setFixedHeight(50) + self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + # delete_btn.clicked.connect(self._delete_data) # TODO add in component + layout_btn.addWidget(self.delete_btn) + + +# def get_edit_buttons( +# add_reset_btn: bool, +# ) -> EditButtonLayout: +# layout_btn = QHBoxLayout() +# # main_layout.addLayout(layout_btn) # TODO add in component +# # save +# save_btn_txt_enabled = "Speichern (Strg + S)" +# save_btn_txt_disabled = "Wird gespeichert..." +# save_btn = QPushButton(save_btn_txt_enabled) +# save_btn.setShortcut("Ctrl+S") +# save_btn.setFixedHeight(50) +# save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) +# # save_btn.clicked.connect(save_data) # TODO add in component +# edit_button_save = EditButton(save_btn, "Speichern (Strg + S)", "Wird gespeichert...") +# layout_btn.addWidget(save_btn) + +# # reset +# edit_button_reset: EditButton | None = None +# if add_reset_btn: +# reset_btn = QPushButton("Zurücksetzen (Strg + Z)") +# reset_btn.setShortcut("Ctrl+Z") +# reset_btn.setFixedHeight(50) +# reset_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) +# # reset_btn.clicked.connect(reset_form) # TODO add in component +# edit_button_reset = EditButton( +# save_btn, "Zurücksetzen (Strg + Z)", "Zurücksetzen (Strg + Z)" +# ) +# layout_btn.addWidget(reset_btn) + +# # delete +# delete_btn = QPushButton("Eintrag löschen (Strg + L)") +# delete_btn.setShortcut("Ctrl+L") +# delete_btn.setFixedHeight(50) +# delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) +# edit_button_delete = EditButton( +# save_btn, "Eintrag löschen (Strg + L)", "Eintrag löschen (Strg + L)" +# ) +# # delete_btn.clicked.connect(self._delete_data) # TODO add in component +# layout_btn.addWidget(delete_btn) + +# return EditButtonLayout( +# layout=layout_btn, +# save=edit_button_save, +# delete=edit_button_delete, +# reset=edit_button_reset, +# ) + + def qt_message_handler(mode, context, message): """forwards internal Qt C++ warnings to Python loggers""" if mode == QtMsgType.QtInfoMsg: -- 2.34.1 From 39d4327e190e6d9e359e110114ed360f83068229 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 9 Jul 2026 14:25:34 +0200 Subject: [PATCH 22/74] change database schema for consulting sessions --- ...2afa4e506448_adding_consulting_sessions.py | 73 +++++++++++++++++++ data_model/project_db_schema.aml | 12 +-- src/wce_crm/data_models.py | 33 +++++++++ src/wce_crm/db.py | 68 +++++++++++++++++ 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/2afa4e506448_adding_consulting_sessions.py diff --git a/alembic/versions/2afa4e506448_adding_consulting_sessions.py b/alembic/versions/2afa4e506448_adding_consulting_sessions.py new file mode 100644 index 0000000..cc27941 --- /dev/null +++ b/alembic/versions/2afa4e506448_adding_consulting_sessions.py @@ -0,0 +1,73 @@ +"""adding consulting sessions + +Revision ID: 2afa4e506448 +Revises: 1a8e957522a4 +Create Date: 2026-07-09 14:21:24.586706 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "2afa4e506448" +down_revision: Union[str, Sequence[str], None] = "1a8e957522a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "beratung_vorgang", + sa.Column("vorgang_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=True), + sa.Column("pers_id", sa.Integer(), nullable=True), + sa.Column("titel", sa.Text(), nullable=False), + sa.Column("beratungs_typ", sa.String(length=20), nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "beratung_einzelberatung", + sa.Column("beratung_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("vorgang_id", sa.Integer(), nullable=False), + sa.Column("nutzer_id", sa.Integer(), nullable=False), + sa.Column("nutzer_name", sa.String(length=20), nullable=False), + sa.Column("zeitstempel", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("ansprechpartner", sa.Text(), nullable=False), + sa.Column("kommunikationsweg", sa.Text(), nullable=False), + sa.Column("thema_crm_matrix", sa.Text(), nullable=True), + sa.Column("anmerkungen", sa.Text(), nullable=True), + sa.Column("rueckmeldung", sa.Text(), nullable=True), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["vorgang_id"], + ["beratung_vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("beratung_einzelberatung") + op.drop_table("beratung_vorgang") + # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 0fe031e..ffd0d98 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -85,7 +85,7 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein pers_id int nullable -> Grunderfassung_Personen(pers_id) | sowohl Pauschal- als auch Individualberatung bei Personenbindung möglich titel text beratung_typ berat_typ(pauschal, individual) - erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind @@ -93,12 +93,14 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden beratung_id int pk {autoIncrement} vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. - erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger nutzer_id int | externe Nutzerverwaltung - nutzer_name text | externe Nuttzerverwaltung - ansprechpartner text nullable - thema text nullable | Verlinkung ins CRM, genaue Gestaltung noch unklar + nutzer_name text | externe Nutzerverwaltung + zeitstempel "timestamp with time zone" | Nutzereingabe + ansprechpartner text + kommunikationsweg text + thema_crm_matrix text nullable | Verlinkung ins CRM, genaue Gestaltung noch unklar anmerkungen text nullable rueckmeldung txt nullable diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index e5ab2b9..667a58c 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -178,6 +178,7 @@ class FlatBaseModel(BaseModel): return dict(items) +# ** InitRec class Grunderfassung_Unternehmen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime Metadaten_erstellung: AwareDatetime | None = None @@ -342,3 +343,35 @@ class Grunderfassung_Sprachen(BaseModel): SP_nachweis: str | None SP_art_nachweis: str | None = None SP_datum_nachweis: datetime.date | None = None + + +# ** Consulting +class Beratungsgespraech(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + un_id: int | None + pers_id: int | None + titel: str + beratungs_typ: str + + # default in SQLAlchemy with lambda and timezone-aware datetime + erstellt: AwareDatetime | None = None + aktualisiert: AwareDatetime | None = None # see above + + +class Beratungsgespraech_Vorgang(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + vorgang_id: int + nutzer_id: int + nutzer_name: str + zeitstempel: AwareDatetime + ansprechpartner: str + kommunikationsweg: str + thema_crm_matrix: str | None + anmerkungen: str | None + rueckmeldung: str | None + + # default in SQLAlchemy with lambda and timezone-aware datetime + erstellt: AwareDatetime | None = None + aktualisiert: AwareDatetime | None = None # see above diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 749fddb..efa699b 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -374,3 +374,71 @@ zuordnung_personen_unternehmen: sql.Table = Table( onupdate=lambda: datetime.datetime.now(datetime.UTC), ), ) + + +beratung_vorgang: sql.Table = Table( + "beratung_vorgang", + MD_MAIN, + Column( + "vorgang_id", + sql.Integer, + primary_key=True, + autoincrement=True, + ), + Column("un_id", sql.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=True), + Column("pers_id", sql.ForeignKey("grunderfassung_personen.pers_id"), nullable=True), + Column("titel", sql.Text, nullable=False), + Column("beratungs_typ", sql.String(20), nullable=False), + Column( + "erstellt", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "aktualisiert", + UTCDateTime, + nullable=True, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), +) + + +beratung_einzelberatung: sql.Table = Table( + "beratung_einzelberatung", + MD_MAIN, + Column( + "beratung_id", + sql.Integer, + primary_key=True, + autoincrement=True, + ), + Column("vorgang_id", sql.ForeignKey("beratung_vorgang.vorgang_id"), nullable=False), + Column("nutzer_id", sql.Integer, nullable=False), + Column("nutzer_name", sql.String(20), nullable=False), + Column( + "zeitstempel", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column("ansprechpartner", sql.Text, nullable=False), + Column("kommunikationsweg", sql.Text, nullable=False), + Column("thema_crm_matrix", sql.Text, nullable=True), + Column("anmerkungen", sql.Text, nullable=True), + Column("rueckmeldung", sql.Text, nullable=True), + Column( + "erstellt", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "aktualisiert", + UTCDateTime, + nullable=True, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), +) -- 2.34.1 From 14357df8cd2d2c3220eba7c805a2955ad608fc8f Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 9 Jul 2026 16:20:01 +0200 Subject: [PATCH 23/74] prepare backend interaction scheme --- src/wce_crm/backend/backend.py | 50 ++++- src/wce_crm/data_models.py | 14 +- src/wce_crm/gui.py | 371 ++++++++++++++++----------------- src/wce_crm/types.py | 1 + 4 files changed, 242 insertions(+), 194 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 6c5dead..5887155 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +from pprint import pformat from typing import TYPE_CHECKING, Any, cast import polars as pl @@ -8,11 +9,12 @@ import sqlalchemy as sql from wce_crm import db from wce_crm.constants import TIMEZONE_CEST +from wce_crm.data_models import Beratungsgespraech_Vorgang from wce_crm.logging import logger_back as logger from wce_crm.types import CompanyInfo, ContactPersonInfo, InitRecType, MainPageEntry if TYPE_CHECKING: - from wce_crm.types import ExtAnId, ExtMaId, RecId + from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId def _transform_for_gui_output( @@ -226,6 +228,52 @@ def initrec_person_delete_initial_recording( raise KeyError(f"Database ID {id_} not found for deletion") +def page_consulting_to_db( + data: Beratungsgespraech_Vorgang, +) -> None: + logger.debug("[Consulting Page] Call Database Routine...") + fk_vorgang_id: int + if data.vorgang_id is None: + # insert new "Vorgang" + insert_data = data.model_dump(exclude={"vorgang_id", "beratungen"}) + logger.debug("[Consulting Page] Call insert with data:\n%s", pformat(insert_data)) + fk_vorgang_id = 42 # TODO replace for real inserted PK + else: + fk_vorgang_id = data.vorgang_id + logger.debug("[Consulting Page] VorgangID already set. ID: %d", fk_vorgang_id) + + rows_for_db_insert: list[dict[str, Any]] = [] + rows_for_db_update: list[dict[str, Any]] = [] + + for cons_session in data.beratungen: + cons_session.vorgang_id = fk_vorgang_id + if cons_session.beratung_id is None: + row_data = cons_session.model_dump(exclude={"beratung_id"}) + rows_for_db_insert.append(row_data) + else: + row_data = cons_session.model_dump() + rows_for_db_update.append(row_data) + + if rows_for_db_update: + # ... update + logger.debug( + "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) + ) + if rows_for_db_insert: + # ... insert + logger.debug( + "[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert) + ) + + +def page_consulting_from_db( + cons_id: ConsId, +) -> Beratungsgespraech_Vorgang: + # TODO add Routine + # get "Vorgang" and all associated sessions to instantiate Pydantic data model + ... + + # // main page interaction def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 667a58c..76690b6 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime import json -from typing import Annotated, Any, Final +from typing import Annotated, Any, Final, Literal from pydantic import ( AwareDatetime, @@ -346,23 +346,27 @@ class Grunderfassung_Sprachen(BaseModel): # ** Consulting -class Beratungsgespraech(BaseModel): +class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + vorgang_id: int | None un_id: int | None pers_id: int | None titel: str - beratungs_typ: str + beratungs_typ: Literal["pauschal", "individuell"] # default in SQLAlchemy with lambda and timezone-aware datetime erstellt: AwareDatetime | None = None aktualisiert: AwareDatetime | None = None # see above + beratungen: list[Beratungsgespraech_Einzelgespraech] -class Beratungsgespraech_Vorgang(BaseModel): + +class Beratungsgespraech_Einzelgespraech(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) - vorgang_id: int + vorgang_id: int | None + beratung_id: int | None nutzer_id: int nutzer_name: str zeitstempel: AwareDatetime diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 0316cef..ac2f537 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -6,6 +6,7 @@ import datetime import enum import re import sys +import time import traceback from collections import defaultdict from collections.abc import Container, Iterable, Sequence @@ -77,6 +78,8 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, + Beratungsgespraech_Einzelgespraech, + Beratungsgespraech_Vorgang, FlatBaseModel, Grunderfassung_Personen, Grunderfassung_Unternehmen, @@ -95,10 +98,9 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) -from wce_crm.types import ExtMaId, RecId, UserId if TYPE_CHECKING: - ... + from wce_crm.types import ConsId, ExtMaId, RecId, UserId K = TypeVar("K") V = TypeVar("V") @@ -343,15 +345,17 @@ class Page_InitRecPerson_State(PageState[Module]): class ConsultingType(enum.StrEnum): PAUSCHAL = enum.auto() - INDIVIUAL = enum.auto() + INDIVIDUAL = enum.auto() @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session + vorgang_id: ConsId | None un_id: RecId | None = None pers_id: RecId | None = None - cons_type: ConsultingType | None = None + titel: str = "TEST-TITEL" # add title later to GUI or build it in the background + beratungs_typ: ConsultingType cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( default_factory=list ) @@ -359,15 +363,16 @@ class Page_Consulting_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_ConsultingSession_State(PageState[Module]): + session: Session nutzer_id: UserId # must be set with the session (always known) nutzer_name: str # must be set with the session (always known) - beratung_id: int | None = None # known and not known entries + beratung_id: int | None # known and not known entries zeitstempel: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) ) ansprechpartner: str | None = None - interaction_scheme: str | None = None - topic: str | None = ( + kommunikationsweg: str | None = None + thema_crm_matrix: str | None = ( None # TODO should be linked to something, target unclear (need specification by customer) ) anmerkungen: str | None = None @@ -376,6 +381,7 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): + session: Session row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) @@ -2829,53 +2835,6 @@ class Page_InitRecCompany(QWidget): self.search_widget_trigger = cast( QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) - # TODO check how this should be done with new architecture - # text_widget_set = search_widgets_by_key( - # self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" - # ) - # assert len(text_widget_set) == 1 - # self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) - # self.search_widget_trigger.textChanged.connect( - # self._custom_set_company_name_contact_person - # ) - - # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' - # TODO check removal - # person_location = search_widgets_by_key( - # self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" - # ) - # assert len(person_location) == 1 - # self.person_location = cast(QComboBox, person_location[0]["widget"]) - # assert isinstance(self.person_location, QComboBox) - # self.person_location.currentIndexChanged.connect(self._custom_county_selection) - - # selection_county = search_widgets_by_key( - # self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" - # ) - # assert len(selection_county) == 1 - # self.selection_county = cast(QComboBox, selection_county[0]["widget"]) - # assert isinstance(self.selection_county, QComboBox) - # self.selection_county.setProperty("styleClass", "stempel") - # self.selection_county.setEnabled(False) - - # def _custom_set_company_name_contact_person(self, value: str) -> None: - # self.text_widget_set.setText(str(value)) - - # def _custom_county_selection(self, idx: int) -> None: - # value = self.person_location.itemData(idx) - # if value == "Inland": - # self.selection_county.setEnabled(True) - # self.selection_county.setProperty("styleClass", "") - # self.selection_county.style().unpolish(self.selection_county) - # self.selection_county.style().polish(self.selection_county) - # self.selection_county.update() - # else: - # self.selection_county.setCurrentIndex(0) - # self.selection_county.setEnabled(False) - # self.selection_county.setProperty("styleClass", "stempel") - # self.selection_county.style().unpolish(self.selection_county) - # self.selection_county.style().polish(self.selection_county) - # self.selection_county.update() def _request_company_profile(self) -> None: req_state = Page_CompanyProfile_State( @@ -2993,23 +2952,6 @@ class Page_InitRecPerson(QWidget): container_layout.addSpacing(15) # --- CUSTOM LOGIC --- - # ** fill 'Kontaktperson -> Namen Unternehmen' - # TODO check how partners are selected with new architecture - # search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") - # assert len(search_res) == 1 - # search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) - # self.search_widget_trigger = cast( - # QLineEdit, search_widget.company_widgets["ma_unternehmensname"] - # ) - # text_widget_set = search_widgets_by_key( - # self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" - # ) - # assert len(text_widget_set) == 1 - # self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) - # self.search_widget_trigger.textChanged.connect( - # self._custom_set_company_name_contact_person - # ) - # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' person_location = search_widgets_by_key( self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" @@ -3028,9 +2970,6 @@ class Page_InitRecPerson(QWidget): self.selection_county.setProperty("styleClass", "stempel") self.selection_county.setEnabled(False) - # def _custom_set_company_name_contact_person(self, value: str) -> None: - # self.text_widget_set.setText(str(value)) - def _custom_county_selection(self, idx: int) -> None: value = self.person_location.itemData(idx) if value == "Inland": @@ -3207,7 +3146,7 @@ class Page_CompanyProfile(QWidget): QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) left_layout.addWidget(master_data_label) - # self.table_master_data = MasterDataTable() + default_labels = tuple(self.COMPANY_FEATURES_MASTER_DATA.values()) self.table_master_data = MasterDataFormWidget(default_labels) left_layout.addWidget(self.table_master_data) @@ -3254,7 +3193,7 @@ class Page_CompanyProfile(QWidget): bottom_right_layout.addWidget(individual_consultancy_label) self.add_btn_individual = QPushButton("+ Hinzufügen") self.add_btn_individual.clicked.connect( - lambda: self._request_consulting(ConsultingType.INDIVIUAL) + lambda: self._request_consulting(ConsultingType.INDIVIDUAL) ) self.add_btn_individual.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed @@ -3317,7 +3256,10 @@ class Page_CompanyProfile(QWidget): "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( - session=self.STATE.session, un_id=self.STATE.rec_id, cons_type=cons_type + session=self.STATE.session, + vorgang_id=None, + un_id=self.STATE.rec_id, + beratungs_typ=cons_type, ) self.consulting_requested.emit(new_state) @@ -3340,7 +3282,7 @@ class Page_CompanyProfile(QWidget): self._enable_add_btns() init_rec = backend.initrec_company_get_initial_recording(self.STATE.rec_id) - ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) + ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) assert ma_id is not None, "no ma_id obtained" comp_info = backend.initrec_comp_search_get_info(ma_id) @@ -3598,7 +3540,7 @@ class Page_Consulting(QWidget): back_requested = Signal(bool) # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") - # update_triggered = Signal() # form saved (data changed for front page) + update_triggered = Signal() # form saved (data changed for front page) def __init__( self, @@ -3606,7 +3548,11 @@ class Page_Consulting(QWidget): ): super().__init__() # state management - self.STATE = Page_Consulting_State(session=session) + self.STATE = Page_Consulting_State( + session=session, + vorgang_id=None, + beratungs_typ=ConsultingType.PAUSCHAL, + ) # Hauptlayout der Seite outer_layout = QHBoxLayout(self) @@ -3695,6 +3641,19 @@ class Page_Consulting(QWidget): container_layout.addSpacing(30) + # --- BUTTONS --- + self.edit_buttons = EditButtons(add_reset_btn=False) + # reset_btn = self.edit_buttons.reset_btn + # assert reset_btn + self.save_btn = self.edit_buttons.save_btn + # self.reset_btn = reset_btn + self.delete_btn = self.edit_buttons.delete_btn + + self.save_btn.clicked.connect(self.save_data) + # self.reset_btn.clicked.connect(self.reset_form) + self.delete_btn.clicked.connect(self._delete_data) + container_layout.addWidget(self.edit_buttons) + type_group_box = QGroupBox("Typ") type_layout = QVBoxLayout(type_group_box) container_layout.addWidget(type_group_box) @@ -3712,8 +3671,8 @@ class Page_Consulting(QWidget): type_layout.addWidget(self.type_individual_btn) # container_layout.addSpacing(50) - table_state = Page_Consulting_Table_State() - self.table_sessions = Page_Consulting_Table(site_state=self.STATE, state=table_state) + table_state = Page_Consulting_Table_State(session=self.STATE.session) + self.table_sessions = Page_Consulting_Table(state=table_state) self.STATE.child_modules.append(self.table_sessions) container_layout.addWidget(self.table_sessions) @@ -3748,6 +3707,28 @@ class Page_Consulting(QWidget): # font-weight: bold; # } # """) + self._sync_state_to_GUI() + + def _delete_data(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call data delete...") + + def _disable_save(self) -> None: + self.save_btn.setEnabled(False) + self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) + + def _activate_delete(self) -> None: + if self.STATE.un_id is not None or self.STATE.pers_id is not None: + self.delete_btn.setEnabled(True) + else: + self.delete_btn.setEnabled(False) + + def _enable_save( + self, + timeout: int = 3000, + ) -> None: + QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) def type_consulting_changed( self, @@ -3759,12 +3740,12 @@ class Page_Consulting(QWidget): if button_id == 0: cons_type = ConsultingType.PAUSCHAL elif button_id == 1: - cons_type = ConsultingType.INDIVIUAL + cons_type = ConsultingType.INDIVIDUAL else: raise RuntimeError(f"No consulting type defined for button id: {button_id}") logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) - self.STATE.cons_type = cons_type + self.STATE.beratungs_typ = cons_type def _debug_get_state(self) -> None: logger_page_consulting.debug("[Consulting Page] Call get state on table...") @@ -3782,10 +3763,10 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting Page] Page errors:\n%s", pformat(errors)) def _sync_state_to_GUI(self) -> None: - match self.STATE.cons_type: + match self.STATE.beratungs_typ: case ConsultingType.PAUSCHAL: self.type_pauschal_btn.setChecked(True) - case ConsultingType.INDIVIUAL: + case ConsultingType.INDIVIDUAL: self.type_individual_btn.setChecked(True) # deactivate if at least one ID is set (type is pre-defined by how @@ -3803,6 +3784,7 @@ class Page_Consulting(QWidget): # this method table_state = self.table_sessions.get_state() self.STATE.cons_sessions = table_state.row_states + self._activate_delete() def validate(self) -> list[str]: errors: list[str] = [] @@ -3817,7 +3799,10 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) set_page_state(self.STATE, new_state) # child modules - table_state = Page_Consulting_Table_State(row_states=self.STATE.cons_sessions) + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + row_states=self.STATE.cons_sessions, + ) self.table_sessions.load_state(table_state) self._sync_state_to_GUI() @@ -3827,6 +3812,98 @@ class Page_Consulting(QWidget): return self.STATE + def save_data(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call saving data...") + self._disable_save() + errors = self.validate() + + if errors: + logger_page_consulting.error( + "[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors) + ) + GUI_validation_error_handling(errors) + self._enable_save() + return + + logger_page_consulting.info("[Consulting Page] Success! All data is valid") + logger_page_consulting.info("[Consulting Page] Get form data call...") + state = self.get_state() + + logger_page_consulting.debug("[Consulting Page] Get state:\n%s", state) + + try: + logger_page_consulting.debug("[Consulting Page] Call Pydantic...") + + pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = [] + + for row in self.STATE.cons_sessions: + cons_session_dict = dc.asdict(row) + cons_session = Beratungsgespraech_Einzelgespraech( + vorgang_id=self.STATE.vorgang_id, + **cons_session_dict, + ) + pydantic_sessions.append(cons_session) + + consulting_process = Beratungsgespraech_Vorgang( + vorgang_id=self.STATE.vorgang_id, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + titel=self.STATE.titel, + beratungs_typ=self.STATE.beratungs_typ, # type: ignore + beratungen=pydantic_sessions, + ) + + except ValidationError as e: + logger_auto_form.error( + "[Consulting Page] Error during Pydantic validation phase:\n%s", + pformat(e.errors()), + ) + pydantic_validation_error_handling(e) + else: + if DEBUG_NO_DATABASE: + return + # logger_page_consulting.debug( + # "[Consulting Page] Validated Pydantic successfully. Model:\n%s", + # pformat(consulting_process.model_dump()), + # ) + # !! should be placed in a try block to handle errors + backend.page_consulting_to_db(consulting_process) + + # # !! this code is only called if the 'try' block was successful + # # save data to database + # # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + # # logger_auto_form.debug( + # # "[Consulting Page] Form data with 'exlude' (must be saved in the database):\n%s", + # # pformat(db_data), + # # ) + + # if self.STATE.rec_id is None: + # logger_auto_form.debug("[Consulting Page] Insert triggered...") + # # should return "vorgang_id" + # # init_rec_id = self.cfg.data_insert(db_data) + # # assert isinstance(init_rec_id, int) + # # self.STATE.rec_id = init_rec_id + # else: + # logger_auto_form.debug("[Consulting Page] Update triggered...") + # # self.cfg.data_update(self.STATE.rec_id, db_data) + + # logger_auto_form.info("[Consulting Page] Data saved successfully") + # self.update_triggered.emit() + # self._activate_delete() + finally: + # always re-enable save, even if error occurred + self._enable_save() + + def load_database( + self, + cons_id: ConsId, + ) -> None: + # TODO add routine + # load data model from database + consulting_process = backend.page_consulting_from_db(cons_id) + # instantiate state from data model + ... + class Page_Consulting_ConsultingSession(QWidget): """child of 'Page_Consulting_DynamicTable'""" @@ -3835,13 +3912,10 @@ class Page_Consulting_ConsultingSession(QWidget): def __init__( self, - site_state: Page_Consulting_State, state: Page_Consulting_ConsultingSession_State, ) -> None: super().__init__() - assert isinstance(site_state, Page_Consulting_State) assert isinstance(state, Page_Consulting_ConsultingSession_State) - self.SITE_STATE = site_state self.STATE = state layout = QHBoxLayout(self) @@ -3866,7 +3940,7 @@ class Page_Consulting_ConsultingSession(QWidget): "Zeitstempel", FormFieldType.DATETIME_FLEXIBLE, required=True, - enable_uuid_key=True, + key="zeitstempel", ) _, self.timestamp = build_and_add_widget( timestamp_def, @@ -3880,7 +3954,7 @@ class Page_Consulting_ConsultingSession(QWidget): FormFieldType.TEXT, placeholder="Freitext", required=True, - enable_uuid_key=True, + key="ansprechpartner", ) _, self.contact = build_and_add_widget( contact_def, @@ -3893,7 +3967,7 @@ class Page_Consulting_ConsultingSession(QWidget): FormFieldType.DROPDOWN, options=[("Persönlich", None), ("Telefon", None), ("Mail", None)], required=True, - enable_uuid_key=True, + key="kommunikationsweg", ) _, self.contact_type = build_and_add_widget( contact_type_def, @@ -3906,7 +3980,7 @@ class Page_Consulting_ConsultingSession(QWidget): FormFieldType.TEXT, placeholder="CRM Links", required=True, - enable_uuid_key=True, + key="thema_crm_matrix", ) _, self.crm_link = build_and_add_widget( crm_link_def, @@ -3917,7 +3991,7 @@ class Page_Consulting_ConsultingSession(QWidget): plain_text_def = FormField[QPlainTextEdit]( "Anmerkungen", FormFieldType.LONGTEXT, - enable_uuid_key=True, + key="anmerkungen", ) _, self.plain_text = build_and_add_widget( plain_text_def, @@ -3928,7 +4002,7 @@ class Page_Consulting_ConsultingSession(QWidget): response_def = FormField[QPlainTextEdit]( "Rückmeldung", FormFieldType.LONGTEXT, - enable_uuid_key=True, + key="rueckmeldung", ) _, self.response = build_and_add_widget( response_def, @@ -3983,16 +4057,18 @@ class Page_Consulting_ConsultingSession(QWidget): set_widget_value(self.user_field, self.STATE.nutzer_name) set_widget_value(self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)) set_widget_value(self.contact, self.STATE.ansprechpartner) - set_widget_value(self.contact_type, self.STATE.interaction_scheme) - set_widget_value(self.crm_link, self.STATE.topic) + set_widget_value(self.contact_type, self.STATE.kommunikationsweg) + set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) set_widget_value(self.plain_text, self.STATE.anmerkungen) set_widget_value(self.response, self.STATE.rueckmeldung) def _sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() self.STATE.ansprechpartner = get_widget_value(self.contact) - self.STATE.interaction_scheme = get_widget_value(self.contact_type) - self.STATE.topic = get_widget_value(self.crm_link) # TODO: change to correct link + self.STATE.kommunikationsweg = get_widget_value(self.contact_type) + self.STATE.thema_crm_matrix = get_widget_value( + self.crm_link + ) # TODO: change to correct link self.STATE.anmerkungen = get_widget_value(self.plain_text) self.STATE.rueckmeldung = get_widget_value(self.response) @@ -4050,17 +4126,14 @@ class Page_Consulting_Table(QWidget): def __init__( self, - site_state: Page_Consulting_State, state: Page_Consulting_Table_State, ) -> None: super().__init__() + self.STATE = state + self.setWindowTitle("Native Dynamische Liste") self.resize(600, 400) - self.STATE = state - # TODO eliminate site state - self.SITE_STATE = site_state - main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 15, 0, 15) main_layout.setSpacing(10) @@ -4215,11 +4288,13 @@ class Page_Consulting_Table(QWidget): ) -> None: if row_state is None: row_state = Page_Consulting_ConsultingSession_State( - nutzer_id=self.SITE_STATE.session.user_id, - nutzer_name=self.SITE_STATE.session.user_name, + session=self.STATE.session, + beratung_id=None, + nutzer_id=self.STATE.session.user_id, + nutzer_name=self.STATE.session.user_name, ) - row = Page_Consulting_ConsultingSession(site_state=self.SITE_STATE, state=row_state) + row = Page_Consulting_ConsultingSession(state=row_state) row.delete_request.connect(self.remove_row) index = self.container_layout.count() - 1 @@ -4254,16 +4329,6 @@ class Page_Consulting_Table(QWidget): for row in self.STATE.child_modules: row_data.append(row.get_state()) - # TODO remove - # for i in range(self.container_layout.count() - 1): - # item = self.container_layout.itemAt(i) - # assert item, "item not found" - # widget = item.widget() - # assert widget, "no widget obtained" - - # if isinstance(widget, Page_Consulting_ConsultingSession): - # row_data.append(widget.get_state()) - assert len(row_data) == len(self.STATE.child_modules), ( "mismatch in row count and data length" ) @@ -4674,21 +4739,6 @@ def GUI_validation_error_handling( msg_box.exec() -# @dc.dataclass(slots=True) -# class EditButton: -# btn: QWidget -# txt_enabled: str -# txt_disabled: str - - -# @dc.dataclass(slots=True, kw_only=True) -# class EditButtonLayout: -# layout: QLayout -# save: EditButton -# delete: EditButton -# reset: EditButton | None - - class EditButtons(QWidget): def __init__( self, @@ -4698,8 +4748,6 @@ class EditButtons(QWidget): layout_btn = QHBoxLayout(self) layout_btn.setContentsMargins(0, 0, 0, 0) - - # main_layout.addLayout(layout_btn) # TODO add in component # save self.save_btn_txt_enabled = "Speichern (Strg + S)" self.save_btn_txt_disabled = "Wird gespeichert..." @@ -4707,9 +4755,7 @@ class EditButtons(QWidget): self.save_btn.setShortcut("Ctrl+S") self.save_btn.setFixedHeight(50) self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - # save_btn.clicked.connect(save_data) # TODO add in component layout_btn.addWidget(self.save_btn) - # reset self.reset_button: QPushButton | None = None if add_reset_btn: @@ -4719,66 +4765,15 @@ class EditButtons(QWidget): self.reset_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - # reset_btn.clicked.connect(reset_form) # TODO add in component layout_btn.addWidget(self.reset_btn) - # delete self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") self.delete_btn.setShortcut("Ctrl+L") self.delete_btn.setFixedHeight(50) self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - # delete_btn.clicked.connect(self._delete_data) # TODO add in component layout_btn.addWidget(self.delete_btn) -# def get_edit_buttons( -# add_reset_btn: bool, -# ) -> EditButtonLayout: -# layout_btn = QHBoxLayout() -# # main_layout.addLayout(layout_btn) # TODO add in component -# # save -# save_btn_txt_enabled = "Speichern (Strg + S)" -# save_btn_txt_disabled = "Wird gespeichert..." -# save_btn = QPushButton(save_btn_txt_enabled) -# save_btn.setShortcut("Ctrl+S") -# save_btn.setFixedHeight(50) -# save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) -# # save_btn.clicked.connect(save_data) # TODO add in component -# edit_button_save = EditButton(save_btn, "Speichern (Strg + S)", "Wird gespeichert...") -# layout_btn.addWidget(save_btn) - -# # reset -# edit_button_reset: EditButton | None = None -# if add_reset_btn: -# reset_btn = QPushButton("Zurücksetzen (Strg + Z)") -# reset_btn.setShortcut("Ctrl+Z") -# reset_btn.setFixedHeight(50) -# reset_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) -# # reset_btn.clicked.connect(reset_form) # TODO add in component -# edit_button_reset = EditButton( -# save_btn, "Zurücksetzen (Strg + Z)", "Zurücksetzen (Strg + Z)" -# ) -# layout_btn.addWidget(reset_btn) - -# # delete -# delete_btn = QPushButton("Eintrag löschen (Strg + L)") -# delete_btn.setShortcut("Ctrl+L") -# delete_btn.setFixedHeight(50) -# delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) -# edit_button_delete = EditButton( -# save_btn, "Eintrag löschen (Strg + L)", "Eintrag löschen (Strg + L)" -# ) -# # delete_btn.clicked.connect(self._delete_data) # TODO add in component -# layout_btn.addWidget(delete_btn) - -# return EditButtonLayout( -# layout=layout_btn, -# save=edit_button_save, -# delete=edit_button_delete, -# reset=edit_button_reset, -# ) - - def qt_message_handler(mode, context, message): """forwards internal Qt C++ warnings to Python loggers""" if mode == QtMsgType.QtInfoMsg: diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index b250ea4..eb8e648 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: UserId: TypeAlias = int RecId: TypeAlias = int +ConsId: TypeAlias = int ExtMaId: TypeAlias = int ExtAnId: TypeAlias = int -- 2.34.1 From 38125eb2ba28073a189c89d42be858dfd98d1a98 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 10 Jul 2026 09:30:14 +0200 Subject: [PATCH 24/74] update DB schema --- ...b0606094_updated_timestamps_nullability.py | 48 +++++++++++++++++++ data_model/project_db_schema.aml | 4 +- src/wce_crm/db.py | 4 +- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/c285b0606094_updated_timestamps_nullability.py diff --git a/alembic/versions/c285b0606094_updated_timestamps_nullability.py b/alembic/versions/c285b0606094_updated_timestamps_nullability.py new file mode 100644 index 0000000..438c227 --- /dev/null +++ b/alembic/versions/c285b0606094_updated_timestamps_nullability.py @@ -0,0 +1,48 @@ +"""updated timestamps nullability + +Revision ID: c285b0606094 +Revises: 2afa4e506448 +Create Date: 2026-07-10 09:22:12.340425 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c285b0606094" +down_revision: Union[str, Sequence[str], None] = "2afa4e506448" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("beratung_einzelberatung") as batch_op: + batch_op.alter_column( + "aktualisiert", + existing_type=sa.DATETIME(), + nullable=False, + ) + + with op.batch_alter_table("beratung_vorgang") as batch_op: + batch_op.alter_column("aktualisiert", existing_type=sa.DATETIME(), nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("beratung_einzelberatung") as batch_op: + batch_op.alter_column( + "aktualisiert", + existing_type=sa.DATETIME(), + nullable=True, + ) + + with op.batch_alter_table("beratung_vorgang") as batch_op: + batch_op.alter_column("aktualisiert", existing_type=sa.DATETIME(), nullable=True) + # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index ffd0d98..ef66756 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -86,7 +86,7 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein titel text beratung_typ berat_typ(pauschal, individual) erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` - aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind @@ -94,7 +94,7 @@ Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elternta beratung_id int pk {autoIncrement} vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger - aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger nutzer_id int | externe Nutzerverwaltung nutzer_name text | externe Nutzerverwaltung zeitstempel "timestamp with time zone" | Nutzereingabe diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index efa699b..25f9129 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -398,7 +398,7 @@ beratung_vorgang: sql.Table = Table( Column( "aktualisiert", UTCDateTime, - nullable=True, + nullable=False, default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), @@ -437,7 +437,7 @@ beratung_einzelberatung: sql.Table = Table( Column( "aktualisiert", UTCDateTime, - nullable=True, + nullable=False, default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), -- 2.34.1 From 6f7593ac9d553f1742125633ac72794827d17187 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 10 Jul 2026 11:52:28 +0200 Subject: [PATCH 25/74] updated data models --- data_model/project_db_schema.aml | 4 ++-- src/wce_crm/data_models.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index ef66756..465b1cc 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -84,10 +84,10 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein un_id int nullable -> Grunderfassung_Unternehmen(un_id) | Pauschalberatungen sind sowohl für Unternehmen als auch Personen zulässig pers_id int nullable -> Grunderfassung_Personen(pers_id) | sowohl Pauschal- als auch Individualberatung bei Personenbindung möglich titel text - beratung_typ berat_typ(pauschal, individual) + beratungs_typ berat_typ(pauschal, individual) erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} - temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind +# temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 76690b6..f007e0b 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -356,8 +356,8 @@ class Beratungsgespraech_Vorgang(BaseModel): beratungs_typ: Literal["pauschal", "individuell"] # default in SQLAlchemy with lambda and timezone-aware datetime - erstellt: AwareDatetime | None = None - aktualisiert: AwareDatetime | None = None # see above + erstellt: AwareDatetime | None = Field(default=None, exclude=True) + aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above beratungen: list[Beratungsgespraech_Einzelgespraech] @@ -377,5 +377,5 @@ class Beratungsgespraech_Einzelgespraech(BaseModel): rueckmeldung: str | None # default in SQLAlchemy with lambda and timezone-aware datetime - erstellt: AwareDatetime | None = None - aktualisiert: AwareDatetime | None = None # see above + erstellt: AwareDatetime | None = Field(default=None, exclude=True) + aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above -- 2.34.1 From 7c79bf8e917a2c7d05c221934903c0d7897c805f Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 10 Jul 2026 11:52:49 +0200 Subject: [PATCH 26/74] basic saving and loading of consultation sessions --- src/wce_crm/backend/backend.py | 187 ++++++++++++++++------ src/wce_crm/gui.py | 281 ++++++++++++++++++++++++--------- 2 files changed, 346 insertions(+), 122 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 5887155..cb43348 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -6,10 +6,11 @@ from typing import TYPE_CHECKING, Any, cast import polars as pl import sqlalchemy as sql +from dopt_basics.result_pattern import wrap_result from wce_crm import db from wce_crm.constants import TIMEZONE_CEST -from wce_crm.data_models import Beratungsgespraech_Vorgang +from wce_crm.data_models import Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang from wce_crm.logging import logger_back as logger from wce_crm.types import CompanyInfo, ContactPersonInfo, InitRecType, MainPageEntry @@ -140,16 +141,19 @@ def initrec_company_get_initial_recording( stmt = db.grunderfassung_unternehmen.select().where( db.grunderfassung_unternehmen.c.un_id == id_ ) - with db.ENGINE.begin() as conn: + with db.ENGINE.connect() as conn: ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found") + results = ret.mappings().all() + if not results: + raise KeyError(f"Database ID {id_} not found") - row = ret.fetchone() + assert len(results) == 1, "more than one company initial recording obtained" + + row = results[0] assert row, "row was not obtained" - return row._asdict() # type: ignore + return dict(row) def initrec_company_delete_initial_recording( @@ -202,16 +206,19 @@ def initrec_person_get_initial_recording( stmt = db.grunderfassung_personen.select().where( db.grunderfassung_personen.c.pers_id == id_ ) - with db.ENGINE.begin() as conn: + with db.ENGINE.connect() as conn: ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found") + results = ret.mappings().all() + if not results: + raise KeyError(f"Database ID {id_} not found") - row = ret.fetchone() + assert len(results) == 1, "more than one person initial recording obtained" + + row = results[0] assert row, "row was not obtained" - return row._asdict() # type: ignore + return dict(row) def initrec_person_delete_initial_recording( @@ -228,50 +235,142 @@ def initrec_person_delete_initial_recording( raise KeyError(f"Database ID {id_} not found for deletion") +@wrap_result(10) def page_consulting_to_db( - data: Beratungsgespraech_Vorgang, -) -> None: - logger.debug("[Consulting Page] Call Database Routine...") - fk_vorgang_id: int - if data.vorgang_id is None: - # insert new "Vorgang" - insert_data = data.model_dump(exclude={"vorgang_id", "beratungen"}) - logger.debug("[Consulting Page] Call insert with data:\n%s", pformat(insert_data)) - fk_vorgang_id = 42 # TODO replace for real inserted PK - else: - fk_vorgang_id = data.vorgang_id - logger.debug("[Consulting Page] VorgangID already set. ID: %d", fk_vorgang_id) + consultation_data: Beratungsgespraech_Vorgang, +) -> Beratungsgespraech_Vorgang: + logger.debug("[Consulting Page] Call database saving routine...") - rows_for_db_insert: list[dict[str, Any]] = [] - rows_for_db_update: list[dict[str, Any]] = [] + with db.ENGINE.begin() as conn: + if consultation_data.vorgang_id is None: + # insert new "Vorgang" + insert_data = consultation_data.model_dump(exclude={"vorgang_id", "beratungen"}) + logger.debug( + "[Consulting Page] Call insert 'Vorgang' with data:\n%s", pformat(insert_data) + ) + + stmt = db.beratung_vorgang.insert() + ret = conn.execute(stmt, insert_data) + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + prim_keys = ret.inserted_primary_key + assert prim_keys + + consultation_data.vorgang_id = cast("ConsId", prim_keys[0]) + logger.debug("[Consulting Page] Inserted 'Vorgang' successfully") - for cons_session in data.beratungen: - cons_session.vorgang_id = fk_vorgang_id - if cons_session.beratung_id is None: - row_data = cons_session.model_dump(exclude={"beratung_id"}) - rows_for_db_insert.append(row_data) else: - row_data = cons_session.model_dump() - rows_for_db_update.append(row_data) + logger.debug( + "[Consulting Page] VorgangID already set. ID: %d", + consultation_data.vorgang_id, + ) - if rows_for_db_update: - # ... update - logger.debug( - "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) - ) - if rows_for_db_insert: - # ... insert - logger.debug( - "[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert) - ) + rows_for_db_insert: list[dict[str, Any]] = [] + rows_for_db_update: list[dict[str, Any]] = [] + + cons_sessions_inserted: list[Beratungsgespraech_Einzelgespraech] = [] + for cons_session in consultation_data.beratungen: + cons_session.vorgang_id = consultation_data.vorgang_id + if cons_session.beratung_id is None: + row_data = cons_session.model_dump(exclude={"beratung_id"}) + rows_for_db_insert.append(row_data) + cons_sessions_inserted.append(cons_session) + else: + row_data = cons_session.model_dump() + # new bind param to avoid name clashes + row_data["b_beratung_id"] = row_data["beratung_id"] + del row_data["beratung_id"] + rows_for_db_update.append(row_data) + + if rows_for_db_update: + # ... update + logger.debug( + "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) + ) + stmt = db.beratung_einzelberatung.update().where( + db.beratung_einzelberatung.c.beratung_id == sql.bindparam("b_beratung_id") + ) + conn.execute(stmt, rows_for_db_update) + + if rows_for_db_insert: + # ... insert + logger.debug( + "[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert) + ) + stmt = db.beratung_einzelberatung.insert().returning( + db.beratung_einzelberatung.c.beratung_id + ) + res = conn.execute(stmt, rows_for_db_insert) + new_cons_session_ids = cast(list[int], [row[0] for row in res.fetchall()]) + + assert len(cons_sessions_inserted) == len(new_cons_session_ids) + for cons_session, new_id in zip(cons_sessions_inserted, new_cons_session_ids): + cons_session.beratung_id = new_id + + return consultation_data +@wrap_result(11) def page_consulting_from_db( cons_id: ConsId, ) -> Beratungsgespraech_Vorgang: - # TODO add Routine # get "Vorgang" and all associated sessions to instantiate Pydantic data model - ... + logger.debug("[Consulting Page] Call database reading routine...") + + with db.ENGINE.connect() as conn: + # get the consultation process + stmt = db.beratung_vorgang.select().where(db.beratung_vorgang.c.vorgang_id == cons_id) + ret = conn.execute(stmt) + + results = ret.mappings().all() + + if not results: + raise KeyError(f"Database ID {cons_id} not found") + + assert len(results) == 1, "more than one consulting process obtained" + consultation_data_db = results[0] + consultation_data = Beratungsgespraech_Vorgang( + vorgang_id=consultation_data_db["vorgang_id"], + un_id=consultation_data_db["un_id"], + pers_id=consultation_data_db["pers_id"], + titel=consultation_data_db["titel"], + beratungs_typ=consultation_data_db["beratungs_typ"], + erstellt=consultation_data_db["erstellt"], + aktualisiert=consultation_data_db["aktualisiert"], + beratungen=[], + ) + + # get all consultation sessions of this process + stmt = db.beratung_einzelberatung.select().where( + db.beratung_einzelberatung.c.vorgang_id == cons_id + ) + ret = conn.execute(stmt) + # empty results possible + if ret.rowcount == 0: + logger.debug("[Consulting Page] No sessions, return directly...") + return consultation_data + + cons_sessions = ret.mappings() + for session in cons_sessions: + cons_session_pydantic = Beratungsgespraech_Einzelgespraech( + beratung_id=session["beratung_id"], + vorgang_id=session["vorgang_id"], + nutzer_id=session["nutzer_id"], + nutzer_name=session["nutzer_name"], + zeitstempel=session["zeitstempel"], + ansprechpartner=session["ansprechpartner"], + kommunikationsweg=session["kommunikationsweg"], + thema_crm_matrix=session["thema_crm_matrix"], + anmerkungen=session["anmerkungen"], + rueckmeldung=session["rueckmeldung"], + erstellt=session["erstellt"], + aktualisiert=session["aktualisiert"], + ) + consultation_data.beratungen.append(cons_session_pydantic) + + logger.debug("[Consulting Page] Returning with sessions attached...") + + return consultation_data # // main page interaction diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index ac2f537..edd2794 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -25,6 +25,7 @@ from typing import ( ) from typing_extensions import override +from dopt_basics.result_pattern import STATUS_HANDLER from pydantic import ( ValidationError, ) @@ -1625,7 +1626,7 @@ class AutoForm(QWidget): logger_auto_form.error( "[Auto-Form] Error during GUI validation phase:\n%s", pformat(errors) ) - GUI_validation_error_handling(errors) + GUI_pydantic_validation_error_handling(errors) self._enable_save() return @@ -2629,6 +2630,7 @@ class Page_NewInitRec(QWidget): back_requested = Signal() # Signal back button company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen" person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson" + consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" def __init__( self, @@ -2661,18 +2663,25 @@ class Page_NewInitRec(QWidget): btn_person.setFixedHeight(40) btn_person.clicked.connect(self._request_initrec_person) + btn_consulting = QPushButton("Beratungsgespräch →") + btn_consulting.setFixedWidth(300) + btn_consulting.setFixedHeight(40) + btn_consulting.clicked.connect(self._request_consulting) + layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) layout.addWidget(btn_company) layout.addWidget(btn_person) + layout.addSpacing(10) + layout.addWidget(btn_consulting) def _request_initrec_company(self) -> None: req_state = Page_InitRecCompany_State( session=self.STATE.session, un_id=None, ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.company_requested.emit(req_state) def _request_initrec_person(self) -> None: @@ -2680,9 +2689,18 @@ class Page_NewInitRec(QWidget): session=self.STATE.session, pers_id=None, ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.person_requested.emit(req_state) + def _request_consulting(self) -> None: + req_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=None, + beratungs_typ=ConsultingType.PAUSCHAL, + ) + logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) + self.consulting_requested.emit(req_state) + def _sync_state_to_GUI(self) -> None: pass @@ -3628,12 +3646,25 @@ class Page_Consulting(QWidget): btn_get_data_table = QPushButton("Debug call 'get_state' from table and page") btn_get_data_table.clicked.connect(self._debug_get_state) container_layout.addWidget(btn_get_data_table) - btn_validate_data_table = QPushButton( - "Debuig call 'validate' from table and page" - ) + btn_validate_data_table = QPushButton("Debug call 'validate' from table and page") btn_validate_data_table.clicked.connect(self._debug_validate) container_layout.addWidget(btn_validate_data_table) + db_index_layout = QHBoxLayout() + self.db_index_field = QLineEdit(placeholderText="setze Index für VorgangID") + self.db_index_field.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + db_index_set_button = QPushButton("setze und lade Vorgang-ID (Datenbankindex)") + db_index_set_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + db_index_set_button.clicked.connect(self._debug_load_database) + + db_index_layout.addWidget(self.db_index_field) + db_index_layout.addWidget(db_index_set_button) + container_layout.addLayout(db_index_layout) + separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -3762,6 +3793,18 @@ class Page_Consulting(QWidget): errors = self.validate() logger_page_consulting.debug("[Consulting Page] Page errors:\n%s", pformat(errors)) + def _debug_load_database(self) -> None: + value = self.db_index_field.text().strip() + if not value: + return + + try: + db_index = int(value) + except ValueError: + return + + self.load_database(db_index) + def _sync_state_to_GUI(self) -> None: match self.STATE.beratungs_typ: case ConsultingType.PAUSCHAL: @@ -3774,9 +3817,18 @@ class Page_Consulting(QWidget): if self.STATE.un_id or self.STATE.pers_id: self.type_pauschal_btn.setEnabled(False) self.type_individual_btn.setEnabled(False) + else: + self.type_pauschal_btn.setEnabled(True) + self.type_individual_btn.setEnabled(True) + # child modules # call to child modules' syncing method not needed because these are initialised by # their own "load_state" method which again calls the modules sync method + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + row_states=self.STATE.cons_sessions, + ) + self.table_sessions.load_state(table_state) def _sync_GUI_to_state(self) -> None: # call to child modules' syncing method not needed because their syncing method is @@ -3798,12 +3850,74 @@ class Page_Consulting(QWidget): ) -> None: logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) set_page_state(self.STATE, new_state) - # child modules - table_state = Page_Consulting_Table_State( - session=self.STATE.session, - row_states=self.STATE.cons_sessions, - ) - self.table_sessions.load_state(table_state) + + self._sync_state_to_GUI() + + def load_database( + self, + cons_id: ConsId, + ) -> None: + # TODO add routine + # load data model from database + res = backend.page_consulting_from_db(cons_id) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "loading procedure." + ), + stack_info=True, + ) + + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Laden fehlgeschlagen", + ( + "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return + + # instantiate state from data model + consulting_process = res.unwrap() + self._load_from_pydantic(consulting_process) + + def _load_from_pydantic( + self, + data: Beratungsgespraech_Vorgang, + update_only: bool = False, + ) -> None: + self.STATE.vorgang_id = data.vorgang_id + self.STATE.beratungs_typ = ConsultingType(data.beratungs_typ) + self.STATE.un_id = data.un_id + self.STATE.pers_id = data.pers_id + self.STATE.titel = data.titel + + if update_only: + for gui_state, pydantic_state in zip(self.STATE.cons_sessions, data.beratungen): + gui_state.beratung_id = pydantic_state.beratung_id + else: + self.STATE.cons_sessions.clear() + for pydantic_state in data.beratungen: + cons_session_state = Page_Consulting_ConsultingSession_State( + session=self.STATE.session, + nutzer_id=pydantic_state.nutzer_id, + nutzer_name=pydantic_state.nutzer_name, + beratung_id=pydantic_state.beratung_id, + zeitstempel=pydantic_state.zeitstempel, + ansprechpartner=pydantic_state.ansprechpartner, + kommunikationsweg=pydantic_state.kommunikationsweg, + thema_crm_matrix=pydantic_state.thema_crm_matrix, + anmerkungen=pydantic_state.anmerkungen, + rueckmeldung=pydantic_state.rueckmeldung, + ) + self.STATE.cons_sessions.append(cons_session_state) self._sync_state_to_GUI() @@ -3821,7 +3935,7 @@ class Page_Consulting(QWidget): logger_page_consulting.error( "[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors) ) - GUI_validation_error_handling(errors) + GUI_pydantic_validation_error_handling(errors) self._enable_save() return @@ -3862,48 +3976,54 @@ class Page_Consulting(QWidget): else: if DEBUG_NO_DATABASE: return - # logger_page_consulting.debug( - # "[Consulting Page] Validated Pydantic successfully. Model:\n%s", - # pformat(consulting_process.model_dump()), - # ) + logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.") # !! should be placed in a try block to handle errors - backend.page_consulting_to_db(consulting_process) + res = backend.page_consulting_to_db(consulting_process) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "saving/updating procedure." + ), + stack_info=True, + ) - # # !! this code is only called if the 'try' block was successful - # # save data to database - # # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) - # # logger_auto_form.debug( - # # "[Consulting Page] Form data with 'exlude' (must be saved in the database):\n%s", - # # pformat(db_data), - # # ) + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Speichern fehlgeschlagen", + ( + "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return - # if self.STATE.rec_id is None: - # logger_auto_form.debug("[Consulting Page] Insert triggered...") - # # should return "vorgang_id" - # # init_rec_id = self.cfg.data_insert(db_data) - # # assert isinstance(init_rec_id, int) - # # self.STATE.rec_id = init_rec_id - # else: - # logger_auto_form.debug("[Consulting Page] Update triggered...") - # # self.cfg.data_update(self.STATE.rec_id, db_data) + updated_data = res.unwrap() + logger_page_consulting.info( + "[Consulting Page] Load state from modified Pydantic model" + ) + self._load_from_pydantic(updated_data, update_only=True) - # logger_auto_form.info("[Consulting Page] Data saved successfully") - # self.update_triggered.emit() - # self._activate_delete() + logger_page_consulting.info("[Consulting Page] Data saved successfully") + # TODO status message successful to user in GUI + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Speichern erfolgreich", + "Die Daten wurden erfolgreich gespeichert", + ) + msg_box.exec() + + # self.update_triggered.emit() + # self._activate_delete() finally: # always re-enable save, even if error occurred self._enable_save() - def load_database( - self, - cons_id: ConsId, - ) -> None: - # TODO add routine - # load data model from database - consulting_process = backend.page_consulting_from_db(cons_id) - # instantiate state from data model - ... - class Page_Consulting_ConsultingSession(QWidget): """child of 'Page_Consulting_DynamicTable'""" @@ -4095,32 +4215,6 @@ class Page_Consulting_ConsultingSession(QWidget): self._sync_state_to_GUI() -def set_page_state( - target: S, - source: S, - deep: bool = False, -) -> None: - """ - transfer all field values generically from 'source' to 'target' - """ - if not (dc.is_dataclass(target) and dc.is_dataclass(source)): - raise TypeError("Both source and atrget must be dataclasses") - - if type(target) is not type(source): - raise TypeError("Both source and target must have the same type") - - for field in dc.fields(source): - if field.name == "child_modules": - continue - - new_value = getattr(source, field.name) - - if deep: - new_value = copy.deepcopy(new_value) - - setattr(target, field.name, new_value) - - class Page_Consulting_Table(QWidget): """child of 'Page_Consulting'""" @@ -4314,10 +4408,14 @@ class Page_Consulting_Table(QWidget): self.remove_row(self.STATE.child_modules[0]) def _sync_state_to_GUI(self) -> None: + self.setUpdatesEnabled(False) + self.reset() for row_state in self.STATE.row_states: self.add_row(row_state) + self.setUpdatesEnabled(True) + def _sync_GUI_to_state(self) -> None: logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") row_data: list[Page_Consulting_ConsultingSession_State] = [] @@ -4367,6 +4465,32 @@ class Page_Consulting_Table(QWidget): #################################################################### +def set_page_state( + target: S, + source: S, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and atrget must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + for field in dc.fields(source): + if field.name == "child_modules": + continue + + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) + + def clear_layout( layout: QLayout | None, ) -> None: @@ -4416,6 +4540,7 @@ class MainWindow(QMainWindow): self.new_initrec_select.back_requested.connect(self.show_main_page) self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) + self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.new_initrec_select) # SITE: 'Grunderfassung Unternehmen' initrec_company_state = Page_InitRecCompany_State(session=self.STATE.session) @@ -4666,7 +4791,7 @@ def global_exception_handler(exc_type, exc_value, exc_traceback): # message to user # check if QApplication exists otherwise crashing of crash handler possible) if QApplication.instance(): - msg_box = get_error_message_box( + msg_box = get_message_box( QMessageBox.Icon.Critical, "Kritischer Fehler", "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", @@ -4684,7 +4809,7 @@ def global_exception_handler(exc_type, exc_value, exc_traceback): sys.exit(1) -def get_error_message_box( +def get_message_box( msg_type: QMessageBox.Icon, title: str, message: str, @@ -4713,7 +4838,7 @@ def pydantic_validation_error_handling( error_texts.append(f"- {error_field}: {reason}, (Pfad: {path})") - msg_box = get_error_message_box( + msg_box = get_message_box( QMessageBox.Icon.Warning, "Fehler bei der Validierung der Eingabedaten", ( @@ -4725,13 +4850,13 @@ def pydantic_validation_error_handling( msg_box.exec() -def GUI_validation_error_handling( +def GUI_pydantic_validation_error_handling( errors: list[str], ) -> None: error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join( errors ) - msg_box = get_error_message_box( + msg_box = get_message_box( QMessageBox.Icon.Warning, "Fehlende oder fehlerhafte Angaben", error_text, -- 2.34.1 From f84cbb21c05a7886a18d432623bd112c7cae43aa Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 10 Jul 2026 12:47:27 +0200 Subject: [PATCH 27/74] flexible date/datetime inputs: fix validation display bug --- src/wce_crm/gui.py | 67 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index edd2794..0447313 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -368,9 +368,8 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): nutzer_id: UserId # must be set with the session (always known) nutzer_name: str # must be set with the session (always known) beratung_id: int | None # known and not known entries - zeitstempel: datetime.datetime = dc.field( - default_factory=lambda: datetime.datetime.now(tz=datetime.UTC) - ) + zeitstempel: datetime.datetime | None = None + # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) ansprechpartner: str | None = None kommunikationsweg: str | None = None thema_crm_matrix: str | None = ( @@ -521,6 +520,8 @@ def build_and_add_widget( if form_field.readonly: widget.setReadOnly(True) + if form_field.required: + widget.setRequired(True) case FormFieldType.DATETIME_FLEXIBLE: widget = FlexibleDateTimeInput() @@ -534,6 +535,8 @@ def build_and_add_widget( if form_field.readonly: widget.setReadOnly(True) + if form_field.required: + widget.setRequired(True) case FormFieldType.DROPDOWN: widget = QComboBox() @@ -1677,7 +1680,8 @@ class AutoForm(QWidget): class FlexibleDateInput(QWidget): def __init__(self, parent=None): super().__init__(parent) - self._read_only = False + self._read_only: bool = False + self._required: bool = False layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -1740,6 +1744,15 @@ class FlexibleDateInput(QWidget): self.calendar.move(pos) self.calendar.show() + def isRequired(self) -> bool: + return self._required + + def setRequired( + self, + required: bool, + ) -> None: + self._required = required + def isReadOnly(self) -> bool: return self._read_only @@ -1770,7 +1783,9 @@ class FlexibleDateInput(QWidget): self.line_edit.setInputMask("99.99.9999;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ") - def _validate_input(self) -> None: + def _validate_input( + self, + ) -> None: logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation") if self._read_only: return @@ -1781,7 +1796,8 @@ class FlexibleDateInput(QWidget): text = self.line_edit.text() # field empty - if not text.replace(".", "").replace("_", "").strip(): + pure_text = text.replace(".", "").replace("_", "").strip() + if not self._required and not pure_text: self.line_edit.setStyleSheet("") return @@ -1796,7 +1812,9 @@ class FlexibleDateInput(QWidget): else: self.line_edit.setStyleSheet("") - def validate(self) -> bool: + def validate( + self, + ) -> bool: logger_custom_fields.debug("[CustomField - FlexDate]: Call validate...") self._validate_input() date = self.get_date() @@ -1851,7 +1869,8 @@ class FlexibleDateInput(QWidget): class FlexibleDateTimeInput(QWidget): def __init__(self, parent=None): super().__init__(parent) - self._read_only = False + self._read_only: bool = False + self._required: bool = False layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -1915,6 +1934,15 @@ class FlexibleDateTimeInput(QWidget): self.calendar.move(pos) self.calendar.show() + def isRequired(self) -> bool: + return self._required + + def setRequired( + self, + required: bool, + ) -> None: + self._required = required + def isReadOnly(self) -> bool: return self._read_only @@ -1945,7 +1973,9 @@ class FlexibleDateTimeInput(QWidget): self.line_edit.setInputMask("99.99.9999 99:99;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") - def _validate_input(self) -> None: + def _validate_input( + self, + ) -> None: logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation") if self._read_only: return @@ -1959,7 +1989,7 @@ class FlexibleDateTimeInput(QWidget): pure_text = ( text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip() ) - if not pure_text: + if not self._required and not pure_text: self.line_edit.setStyleSheet("") return @@ -1972,7 +2002,9 @@ class FlexibleDateTimeInput(QWidget): else: self.line_edit.setStyleSheet("") - def validate(self) -> bool: + def validate( + self, + ) -> bool: logger_custom_fields.debug("[CustomField - FlexDatetime]: Call validate...") self._validate_input() datetime = self.get_datetime() @@ -3665,6 +3697,13 @@ class Page_Consulting(QWidget): db_index_layout.addWidget(db_index_set_button) container_layout.addLayout(db_index_layout) + test_flex_datetime = FlexibleDateTimeInput() + test_flex_datetime.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + + container_layout.addWidget(test_flex_datetime) + separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -4067,7 +4106,6 @@ class Page_Consulting_ConsultingSession(QWidget): layout, widget_registry=self.widget_registry, ) - self.timestamp.set_pydatetime(datetime.datetime.now()) contact_def = FormField[QLineEdit]( "Kontaktperson", @@ -4175,7 +4213,8 @@ class Page_Consulting_ConsultingSession(QWidget): "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) set_widget_value(self.user_field, self.STATE.nutzer_name) - set_widget_value(self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)) + if self.STATE.zeitstempel is not None: + set_widget_value(self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)) set_widget_value(self.contact, self.STATE.ansprechpartner) set_widget_value(self.contact_type, self.STATE.kommunikationsweg) set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) @@ -4200,7 +4239,7 @@ class Page_Consulting_ConsultingSession(QWidget): def validate(self) -> list[str]: logger_page_consulting.debug( ">>>>> Call validate on Session. Widget registry:\n%s", - pprint_registry(self.widget_registry), + pformat_registry(self.widget_registry), ) return validate_form_data(self.widget_registry) -- 2.34.1 From 6eb358e0aa6fca567dc8338250a42675c8f44312 Mon Sep 17 00:00:00 2001 From: foefl Date: Sat, 11 Jul 2026 09:48:48 +0200 Subject: [PATCH 28/74] update schema for consulting session deletion tracking --- ...cab29c_timestamps_as_deletion_indicator.py | 41 +++++++++++++++++++ data_model/project_db_schema.aml | 3 +- src/wce_crm/db.py | 10 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py diff --git a/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py b/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py new file mode 100644 index 0000000..72acc16 --- /dev/null +++ b/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py @@ -0,0 +1,41 @@ +"""timestamps as deletion indicator + +Revision ID: 830416cab29c +Revises: c285b0606094 +Create Date: 2026-07-11 06:12:14.423902 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "830416cab29c" +down_revision: Union[str, Sequence[str], None] = "c285b0606094" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "beratung_einzelberatung", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + op.add_column( + "beratung_vorgang", sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("beratung_vorgang", "geloescht") + op.drop_column("beratung_einzelberatung", "geloescht") + # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 465b1cc..159ebf4 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -87,6 +87,7 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein beratungs_typ berat_typ(pauschal, individual) erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} + geloescht "timestamp with time zone" nullable # temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind @@ -103,4 +104,4 @@ Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elternta thema_crm_matrix text nullable | Verlinkung ins CRM, genaue Gestaltung noch unklar anmerkungen text nullable rueckmeldung txt nullable - + geloescht "timestamp with time zone" nullable diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 25f9129..60df268 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -402,6 +402,11 @@ beratung_vorgang: sql.Table = Table( default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), + Column( + "geloescht", + UTCDateTime, + nullable=True, + ), ) @@ -441,4 +446,9 @@ beratung_einzelberatung: sql.Table = Table( default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), + Column( + "geloescht", + UTCDateTime, + nullable=True, + ), ) -- 2.34.1 From 5563e5a6b988fbae7d4a420748d19cd99f18e441 Mon Sep 17 00:00:00 2001 From: foefl Date: Sat, 11 Jul 2026 10:02:58 +0200 Subject: [PATCH 29/74] added functionality for consulting session saving, updating and soft deleting --- src/wce_crm/backend/backend.py | 32 ++++- src/wce_crm/data_models.py | 12 +- src/wce_crm/gui.py | 223 +++++++++++++++++++-------------- 3 files changed, 162 insertions(+), 105 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index cb43348..970e4d1 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -244,13 +244,16 @@ def page_consulting_to_db( with db.ENGINE.begin() as conn: if consultation_data.vorgang_id is None: # insert new "Vorgang" - insert_data = consultation_data.model_dump(exclude={"vorgang_id", "beratungen"}) + insert_data_process = consultation_data.model_dump( + exclude={"vorgang_id", "beratungen"} + ) logger.debug( - "[Consulting Page] Call insert 'Vorgang' with data:\n%s", pformat(insert_data) + "[Consulting Page] Call insert 'Vorgang' with data:\n%s", + pformat(insert_data_process), ) stmt = db.beratung_vorgang.insert() - ret = conn.execute(stmt, insert_data) + ret = conn.execute(stmt, insert_data_process) if ret.rowcount == 0: raise IOError("Entry was not inserted correctly") prim_keys = ret.inserted_primary_key @@ -261,9 +264,14 @@ def page_consulting_to_db( else: logger.debug( - "[Consulting Page] VorgangID already set. ID: %d", + "[Consulting Page] VorgangID already set. ID: %d. Update...", consultation_data.vorgang_id, ) + update_data_process = consultation_data.model_dump(exclude={"beratungen"}) + stmt = db.beratung_vorgang.update().where( + db.beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id + ) + conn.execute(stmt, update_data_process) rows_for_db_insert: list[dict[str, Any]] = [] rows_for_db_update: list[dict[str, Any]] = [] @@ -337,12 +345,24 @@ def page_consulting_from_db( beratungs_typ=consultation_data_db["beratungs_typ"], erstellt=consultation_data_db["erstellt"], aktualisiert=consultation_data_db["aktualisiert"], + geloescht=consultation_data_db["geloescht"], beratungen=[], ) - # get all consultation sessions of this process + if consultation_data.geloescht is not None: + logger.info( + ( + "[Consulting Page] Obtained entry for ID=%d. This entry was marked " + "as deleted." + ), + cons_id, + ) + + # get all consultation sessions of this process, only the ones + # which are not marked as deleted stmt = db.beratung_einzelberatung.select().where( - db.beratung_einzelberatung.c.vorgang_id == cons_id + db.beratung_einzelberatung.c.vorgang_id == cons_id, + db.beratung_einzelberatung.c.geloescht.is_(None), ) ret = conn.execute(stmt) # empty results possible diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index f007e0b..3c5772c 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import enum import json from typing import Annotated, Any, Final, Literal @@ -38,7 +39,7 @@ def translate_pydantic_errors( err_type = error.get("type", None) if err_type is None or err_type not in GERMAN_ERROR_MESSAGES: - error["msg"] = "[fehleden Übersetzung, nur Englisch]: " + error["msg"] + error["msg"] = "[fehlende Übersetzung, nur Englisch]: " + error["msg"] translated_errors.append(error) continue @@ -346,6 +347,11 @@ class Grunderfassung_Sprachen(BaseModel): # ** Consulting +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIDUAL = enum.auto() + + class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) @@ -353,11 +359,12 @@ class Beratungsgespraech_Vorgang(BaseModel): un_id: int | None pers_id: int | None titel: str - beratungs_typ: Literal["pauschal", "individuell"] + beratungs_typ: ConsultingType # default in SQLAlchemy with lambda and timezone-aware datetime erstellt: AwareDatetime | None = Field(default=None, exclude=True) aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above + geloescht: AwareDatetime | None = None beratungen: list[Beratungsgespraech_Einzelgespraech] @@ -379,3 +386,4 @@ class Beratungsgespraech_Einzelgespraech(BaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime erstellt: AwareDatetime | None = Field(default=None, exclude=True) aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above + geloescht: AwareDatetime | None = None diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 0447313..4f9cdd9 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3,10 +3,8 @@ from __future__ import annotations import copy import dataclasses as dc import datetime -import enum import re import sys -import time import traceback from collections import defaultdict from collections.abc import Container, Iterable, Sequence @@ -33,10 +31,13 @@ from PySide6.QtCore import ( QDate, QDateTime, QEvent, + QLibraryInfo, + QLocale, QObject, Qt, QTimer, QtMsgType, + QTranslator, Signal, qInstallMessageHandler, ) @@ -81,6 +82,7 @@ from wce_crm.data_models import ( COLUMN_SEP, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, + ConsultingType, FlatBaseModel, Grunderfassung_Personen, Grunderfassung_Unternehmen, @@ -344,11 +346,6 @@ class Page_InitRecPerson_State(PageState[Module]): pers_id: RecId | None = None -class ConsultingType(enum.StrEnum): - PAUSCHAL = enum.auto() - INDIVIDUAL = enum.auto() - - @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -360,6 +357,7 @@ class Page_Consulting_State(PageState[Module]): cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( default_factory=list ) + ist_geloescht: bool = False @dc.dataclass(slots=True, kw_only=True) @@ -377,6 +375,7 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): ) anmerkungen: str | None = None rueckmeldung: str | None = None + ist_geloescht: bool = False @dc.dataclass(slots=True, kw_only=True) @@ -845,29 +844,6 @@ def get_form_data( else: value = get_widget_value(widget) - # elif isinstance(widget, QDateEdit): - # qt_date = widget.date() - # value = qt_date.toPython() - # elif isinstance(widget, FlexibleDateInput): - # value = widget.get_pydate() - # elif isinstance(widget, FlexibleDateTimeInput): - # value = widget.get_pydatetime() - # elif isinstance(widget, QComboBox): - # value = widget.currentData() - # elif isinstance( - # widget, - # ( - # DynamicListWidget, - # DynamicDropdownWidgetNumeric, - # DynamicDropdownWidgetOption, - # CustomWidget, - # ), - # ): - # # this is a special data structure with some assumptions of the widget's internals - # # DynamicListWidget: this is a list -> each dynamic list contains a list - # # of such dictionaries (handled below) - # value = widget.get_form_data() - if isinstance(value, dict): raw_data.update(value) else: @@ -1854,9 +1830,11 @@ class FlexibleDateInput(QWidget): # valid or invalid QDate return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy") - def get_pydate(self) -> datetime.date: + def get_pydate(self) -> datetime.date | None: # valid or invalid QDate date = self.get_date() + if not date.isValid(): + return None py_date = cast(datetime.date, date.toPython()) return py_date @@ -2065,7 +2043,7 @@ class FlexibleDateTimeInput(QWidget): # valid or invalid QDateTime return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm") - def get_pydatetime(self) -> datetime.datetime: + def get_pydatetime(self) -> datetime.datetime | None: """returns current value of the field as UTC datetime Returns @@ -2075,6 +2053,8 @@ class FlexibleDateTimeInput(QWidget): """ # valid or invalid datetime dt = self.get_datetime() + if not dt.isValid(): + return None py_dt = cast(datetime.datetime, dt.toPython()) py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST) @@ -2488,7 +2468,6 @@ class DynamicDropdownWidgetOption(QWidget): sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form) form_field_def = copy.copy(self.assigned_form_fields) - # form_field_def.label = form_field_def.enhanced_label(f"{number_form}") sub_form.full_keys = _build_ui_recursively( schema=form_field_def, @@ -2746,6 +2725,7 @@ class Page_NewInitRec(QWidget): self, new_state: Page_NewInitRec_State, ) -> None: + set_page_state(self.STATE, new_state) self._sync_state_to_GUI() def get_state(self) -> Page_NewInitRec_State: @@ -3371,42 +3351,6 @@ class Page_CompanyProfile(QWidget): txt = f"Unternehmen: {name}" self.title_company_name.setText(txt) - # def load_from_id( - # self, - # id_: InitRecId, - # ) -> None: - # assert id_ is not None, "tried to load with InitRecId None" - # assert id_, "tried to load with invalid InitRecId" - # logger_gui.debug(f"Called function to load with ID: {id_}") - - # if id_ < 0: - # # just clear and do nothing else - # logger_gui.debug("just reset with ID < 0") - # self.table_master_data.reset() - # self.set_company_name("") - # self.current_id = id_ - # return - - # if id_ == self.current_id: - # logger_gui.debug("ID did not change. Do nothing") - # self.current_id = id_ - # return - - # init_rec = backend.initrec_company_get_initial_recording(id_) - # ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"]) - # assert ma_id is not None, "no ma_id obtained" - # comp_info = backend.initrec_comp_search_get_info(ma_id) - - # data: dict[str, Any] = {} - - # for feature, value in comp_info.items(): - # if feature in self.COMPANY_FEATURES_MASTER_DATA: - # data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value - - # self.table_master_data.load_data(data) - # self.set_company_name(data["Name:"]) - # self.current_id = id_ - class MasterDataTable(QWidget): def __init__(self): @@ -3668,13 +3612,6 @@ class Page_Consulting(QWidget): separator1.setFrameShadow(QFrame.Shadow.Sunken) container_layout.addWidget(separator1) - # layout_change_title = QHBoxLayout() - # self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...") - # layout_change_title.addWidget(self.change_title_text) - # change_title_btn = QPushButton("ändern") - # layout_change_title.addWidget(change_title_btn) - # change_title_btn.clicked.connect(self.set_company_name) - # container_layout.addLayout(layout_change_title) btn_get_data_table = QPushButton("Debug call 'get_state' from table and page") btn_get_data_table.clicked.connect(self._debug_get_state) container_layout.addWidget(btn_get_data_table) @@ -3937,13 +3874,23 @@ class Page_Consulting(QWidget): self.STATE.un_id = data.un_id self.STATE.pers_id = data.pers_id self.STATE.titel = data.titel + self.STATE.ist_geloescht = data.geloescht is not None if update_only: - for gui_state, pydantic_state in zip(self.STATE.cons_sessions, data.beratungen): + relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht] + rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None] + print(f">>>>>>> {pformat(relevant_gui_states)}") + assert len(relevant_gui_states) == len(rel_cons_session_states) + for gui_state, pydantic_state in zip( + relevant_gui_states, rel_cons_session_states + ): gui_state.beratung_id = pydantic_state.beratung_id else: self.STATE.cons_sessions.clear() for pydantic_state in data.beratungen: + # Deleted sessions are not part of the retrieved data from the backend if this + # page is initialised from a fresh backend call to get data. (Updates do + # indeed have data which must be filtered -- see above) cons_session_state = Page_Consulting_ConsultingSession_State( session=self.STATE.session, nutzer_id=pydantic_state.nutzer_id, @@ -3989,10 +3936,32 @@ class Page_Consulting(QWidget): pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = [] + timestamp_deleted: datetime.datetime | None = None + if self.STATE.ist_geloescht: + timestamp_deleted = datetime.datetime.now(datetime.UTC) + for row in self.STATE.cons_sessions: + logger_page_consulting.debug("[Consulting Page] Row state:\n%s", row) + if row.beratung_id is None and row.ist_geloescht: + logger_page_consulting.debug( + ( + "[Consulting Page] Session which was never saved in the database " + "and is marked for deletion - ignore." + ) + ) + continue + + timestamp_deleted_session: datetime.datetime | None = None + if timestamp_deleted is not None: + # if process is deleted, all sessions must be marked as deleted + timestamp_deleted_session = timestamp_deleted + elif row.ist_geloescht: + timestamp_deleted_session = datetime.datetime.now(datetime.UTC) + cons_session_dict = dc.asdict(row) cons_session = Beratungsgespraech_Einzelgespraech( vorgang_id=self.STATE.vorgang_id, + geloescht=timestamp_deleted_session, **cons_session_dict, ) pydantic_sessions.append(cons_session) @@ -4003,6 +3972,7 @@ class Page_Consulting(QWidget): pers_id=self.STATE.pers_id, titel=self.STATE.titel, beratungs_typ=self.STATE.beratungs_typ, # type: ignore + geloescht=timestamp_deleted, beratungen=pydantic_sessions, ) @@ -4016,6 +3986,10 @@ class Page_Consulting(QWidget): if DEBUG_NO_DATABASE: return logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.") + logger_page_consulting.debug( + "[Consulting Page] Pydantic model:\n%s", consulting_process + ) + # !! should be placed in a try block to handle errors res = backend.page_consulting_to_db(consulting_process) if res.status != STATUS_HANDLER.SUCCESS: @@ -4042,21 +4016,20 @@ class Page_Consulting(QWidget): msg_box.exec() return - updated_data = res.unwrap() + updated_consulting_process = res.unwrap() logger_page_consulting.info( "[Consulting Page] Load state from modified Pydantic model" ) - self._load_from_pydantic(updated_data, update_only=True) + self._load_from_pydantic(updated_consulting_process, update_only=True) logger_page_consulting.info("[Consulting Page] Data saved successfully") - # TODO status message successful to user in GUI msg_box = get_message_box( QMessageBox.Icon.Information, "Speichern erfolgreich", "Die Daten wurden erfolgreich gespeichert", ) msg_box.exec() - + # TODO check triggers for rest of application # self.update_triggered.emit() # self._activate_delete() finally: @@ -4173,7 +4146,7 @@ class Page_Consulting_ConsultingSession(QWidget): self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;") - self.btn_delete.clicked.connect(lambda: self.delete_request.emit(self)) + self.btn_delete.clicked.connect(self._delete_request) self.btn_delete.setFixedWidth(30) layout.addWidget(self.btn_delete) @@ -4206,7 +4179,17 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - self._sync_state_to_GUI() # ?? only needed if entry is from database? + self._sync_state_to_GUI() + + def _delete_request(self) -> None: + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + if confirm: + self.STATE.ist_geloescht = True + self.delete_request.emit(self) def _sync_state_to_GUI(self) -> None: logger_page_consulting.debug( @@ -4276,7 +4259,7 @@ class Page_Consulting_Table(QWidget): self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_add.setObjectName("BlueAddButton") # ID vergeben main_layout.addWidget(self.btn_add) - self.btn_add.clicked.connect(lambda: self.add_row(None)) + self.btn_add.clicked.connect(lambda: self.add_row(None, False)) self.btn_add.setStyleSheet(""" #BlueAddButton { @@ -4417,7 +4400,8 @@ class Page_Consulting_Table(QWidget): def add_row( self, - row_state: Page_Consulting_ConsultingSession_State | None = None, + row_state: Page_Consulting_ConsultingSession_State | None, + state_exists: bool, ) -> None: if row_state is None: row_state = Page_Consulting_ConsultingSession_State( @@ -4429,10 +4413,13 @@ class Page_Consulting_Table(QWidget): row = Page_Consulting_ConsultingSession(state=row_state) row.delete_request.connect(self.remove_row) + # sys.exit(0) index = self.container_layout.count() - 1 self.container_layout.insertWidget(index, row) self.STATE.child_modules.append(row) + if not state_exists: + self.STATE.row_states.append(row.get_state()) def remove_row( self, @@ -4440,36 +4427,49 @@ class Page_Consulting_Table(QWidget): ) -> None: self.container_layout.removeWidget(table_row_widget) self.STATE.child_modules.remove(table_row_widget) + # do not remove the corresponding state since it is tracked + # to be post-processed when the database saving operation is triggered table_row_widget.deleteLater() - def reset(self) -> None: + def remove_all_rows(self) -> None: while self.STATE.child_modules: self.remove_row(self.STATE.child_modules[0]) def _sync_state_to_GUI(self) -> None: self.setUpdatesEnabled(False) - self.reset() + self.remove_all_rows() + # states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht] + # for row_state in states_to_show: for row_state in self.STATE.row_states: - self.add_row(row_state) + if not row_state.ist_geloescht: + self.add_row(row_state, True) self.setUpdatesEnabled(True) def _sync_GUI_to_state(self) -> None: logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") - row_data: list[Page_Consulting_ConsultingSession_State] = [] + logger_page_consulting.debug( + ( + "[Page -- Consulting] Table: Row data is managed when entries are added. " + "There is no action needed. All states are obtained by reference and set " + "by their row components/widgets. Therefore, this method calls the " + "method `get_state` of all ACTIVE components to update their respective " + "states with the current user inputs." + ) + ) logger_page_consulting.debug( "[Page -- Consulting] Table - Child modules: %s", pformat(self.STATE.child_modules), ) + # Child modules are ACTIVE elements, viewable and changeable by the user. + # The states are already part of this widget and linked by reference, but + # the current user input must be synced with these states. Therefore, the + # `get_state` method of these children is called, which implicitly updates + # their state. The returned state reference is not needed. for row in self.STATE.child_modules: - row_data.append(row.get_state()) - - assert len(row_data) == len(self.STATE.child_modules), ( - "mismatch in row count and data length" - ) - self.STATE.row_states = row_data + _ = row.get_state() def get_state(self) -> Page_Consulting_Table_State: logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") @@ -4536,7 +4536,6 @@ def clear_layout( if layout is None: return - # for idx in range(start_idx, layout.count()): while layout.count(): child = layout.takeAt(0) if child is None: @@ -4864,6 +4863,25 @@ def get_message_box( return msg_box +def get_user_confirmation( + parent_widget: QWidget, + title: str, + message: str, +) -> bool: + response = QMessageBox.question( + parent_widget, + title, + message, + buttons=(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No), + defaultButton=QMessageBox.StandardButton.No, + ) + + if response == QMessageBox.StandardButton.Yes: + return True + else: + return False + + def pydantic_validation_error_handling( val_error: ValidationError, ) -> None: @@ -4959,15 +4977,26 @@ if TYPE_CHECKING: _w1: type[WrapperModule] = Page_InitRecCompany _w2: type[WrapperModule] = Page_InitRecPerson -if __name__ == "__main__": + +def main() -> None: sys.excepthook = global_exception_handler qInstallMessageHandler(qt_message_handler) try: app = QApplication(sys.argv) app.setStyleSheet(QSS) + + translator = QTranslator() + translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + if translator.load(QLocale.system(), "qtbase", "_", translation_path): + app.installTranslator(translator) + window = MainWindow() window.show() sys.exit(app.exec()) except Exception as err: logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True) + + +if __name__ == "__main__": + main() -- 2.34.1 From 3841b645e1b2213913874d637517596459e66d7a Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 13 Jul 2026 11:23:19 +0200 Subject: [PATCH 30/74] update `Ruff` --- pdm.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/pdm.lock b/pdm.lock index a9df463..195d484 100644 --- a/pdm.lock +++ b/pdm.lock @@ -3571,29 +3571,29 @@ files = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.21" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["lint"] files = [ - {file = "ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f"}, - {file = "ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e"}, - {file = "ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48"}, - {file = "ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5"}, - {file = "ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed"}, - {file = "ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188"}, - {file = "ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e"}, + {file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"}, + {file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"}, + {file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"}, + {file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"}, + {file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"}, + {file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"}, + {file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"}, ] [[package]] -- 2.34.1 From 0cddb58f0604147cb05c96d86bae243865acac12 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 13 Jul 2026 11:59:42 +0200 Subject: [PATCH 31/74] info banners for linking --- src/wce_crm/gui.py | 103 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 4f9cdd9..0cc4698 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3634,13 +3634,6 @@ class Page_Consulting(QWidget): db_index_layout.addWidget(db_index_set_button) container_layout.addLayout(db_index_layout) - test_flex_datetime = FlexibleDateTimeInput() - test_flex_datetime.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - - container_layout.addWidget(test_flex_datetime) - separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -3648,6 +3641,14 @@ class Page_Consulting(QWidget): container_layout.addSpacing(30) + # --- BANNER --- + self.link_banner_container = QWidget() + self.link_banners: list[InfoBanner] = [] + self.link_banner_layout = QVBoxLayout(self.link_banner_container) + self.link_banner_layout.setContentsMargins(0, 0, 0, 0) + self.link_banner_layout.setSpacing(6) + container_layout.addWidget(self.link_banner_container) + # --- BUTTONS --- self.edit_buttons = EditButtons(add_reset_btn=False) # reset_btn = self.edit_buttons.reset_btn @@ -3753,6 +3754,7 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) self.STATE.beratungs_typ = cons_type + self._sync_state_to_GUI() def _debug_get_state(self) -> None: logger_page_consulting.debug("[Consulting Page] Call get state on table...") @@ -3781,6 +3783,29 @@ class Page_Consulting(QWidget): self.load_database(db_index) + def _open_linking_dialogue(self) -> None: + logger_page_consulting.info("[Consulting Page] Linking dialogue triggered") + + def _clear_linking_banners(self) -> None: + while self.link_banners: + banner = self.link_banners[0] + self.link_banner_layout.removeWidget(banner) + self.link_banners.remove(banner) + banner.deleteLater() + + def _add_linking_banner( + self, + text: str, + button_text: str, + ) -> None: + banner = InfoBanner( + text=text, + button_text=button_text, + ) + self.link_banners.append(banner) + banner.link_clicked.connect(self._open_linking_dialogue) + self.link_banner_layout.addWidget(banner) + def _sync_state_to_GUI(self) -> None: match self.STATE.beratungs_typ: case ConsultingType.PAUSCHAL: @@ -3797,6 +3822,26 @@ class Page_Consulting(QWidget): self.type_pauschal_btn.setEnabled(True) self.type_individual_btn.setEnabled(True) + # info banners needed + self._clear_linking_banners() + if self.STATE.un_id is None and self.STATE.pers_id is None: + # link missing + self._add_linking_banner( + text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", + button_text="Jetzt verknüpfen", + ) + elif ( + self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL + and self.STATE.pers_id is None + ): + self._add_linking_banner( + text=( + "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " + "einer Person fehlt." + ), + button_text="Jetzt verknüpfen", + ) + # child modules # call to child modules' syncing method not needed because these are initialised by # their own "load_state" method which again calls the modules sync method @@ -4501,6 +4546,50 @@ class Page_Consulting_Table(QWidget): pass +class InfoBanner(QFrame): + link_clicked = Signal() + + def __init__( + self, + text: str, + button_text: str, + parent: QWidget | None = None, + ): + super().__init__(parent) + + self.setStyleSheet(""" + InfoBanner { + background-color: #FFF3CD; + border: 1px solid #FFEBAA; + border-radius: 6px; + } + QLabel { + color: #856404; + font-size: 13px; + } + QPushButton { + background-color: #856404; + color: white; + border-radius: 4px; + padding: 4px 10px; + font-weight: bold; + } + QPushButton:hover { + background-color: #6C5204; + } + """) + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 10, 15, 10) + + self.label = QLabel(text) + self.button = QPushButton(button_text) + layout.addWidget(self.label) + layout.addStretch() + layout.addWidget(self.button) + + self.button.clicked.connect(self.link_clicked.emit) + + #################################################################### -- 2.34.1 From b41d55db7dddb48d59a55d1da1bba9be050f0aa8 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 13 Jul 2026 12:03:49 +0200 Subject: [PATCH 32/74] consulting page: info banners for missing links with dummy call --- src/wce_crm/gui.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 0cc4698..d5ffdc3 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3642,12 +3642,12 @@ class Page_Consulting(QWidget): container_layout.addSpacing(30) # --- BANNER --- - self.link_banner_container = QWidget() - self.link_banners: list[InfoBanner] = [] - self.link_banner_layout = QVBoxLayout(self.link_banner_container) - self.link_banner_layout.setContentsMargins(0, 0, 0, 0) - self.link_banner_layout.setSpacing(6) - container_layout.addWidget(self.link_banner_container) + self.info_banners_container = QWidget() + self.info_banners: list[InfoBanner] = [] + self.info_banners_layout = QVBoxLayout(self.info_banners_container) + self.info_banners_layout.setContentsMargins(0, 0, 0, 0) + self.info_banners_layout.setSpacing(6) + container_layout.addWidget(self.info_banners_container) # --- BUTTONS --- self.edit_buttons = EditButtons(add_reset_btn=False) @@ -3786,11 +3786,11 @@ class Page_Consulting(QWidget): def _open_linking_dialogue(self) -> None: logger_page_consulting.info("[Consulting Page] Linking dialogue triggered") - def _clear_linking_banners(self) -> None: - while self.link_banners: - banner = self.link_banners[0] - self.link_banner_layout.removeWidget(banner) - self.link_banners.remove(banner) + def _clear_info_banners(self) -> None: + while self.info_banners: + banner = self.info_banners[0] + self.info_banners_layout.removeWidget(banner) + self.info_banners.remove(banner) banner.deleteLater() def _add_linking_banner( @@ -3802,9 +3802,9 @@ class Page_Consulting(QWidget): text=text, button_text=button_text, ) - self.link_banners.append(banner) + self.info_banners.append(banner) banner.link_clicked.connect(self._open_linking_dialogue) - self.link_banner_layout.addWidget(banner) + self.info_banners_layout.addWidget(banner) def _sync_state_to_GUI(self) -> None: match self.STATE.beratungs_typ: @@ -3823,7 +3823,7 @@ class Page_Consulting(QWidget): self.type_individual_btn.setEnabled(True) # info banners needed - self._clear_linking_banners() + self._clear_info_banners() if self.STATE.un_id is None and self.STATE.pers_id is None: # link missing self._add_linking_banner( -- 2.34.1 From eb66b435a045eb5db2dd5a17e9956cb4918e5fa4 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 13 Jul 2026 17:50:59 +0200 Subject: [PATCH 33/74] refactoring and basic loading of existing consultations on the company profile --- src/wce_crm/backend/backend.py | 68 +++- src/wce_crm/data_models.py | 218 ++++++++++++- src/wce_crm/gui.py | 552 +++++++++++++++++---------------- src/wce_crm/types.py | 20 ++ 4 files changed, 572 insertions(+), 286 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 970e4d1..c3f701d 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -10,9 +10,20 @@ from dopt_basics.result_pattern import wrap_result from wce_crm import db from wce_crm.constants import TIMEZONE_CEST -from wce_crm.data_models import Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang +from wce_crm.data_models import ( + Beratungsgespraech_Einzelgespraech, + Beratungsgespraech_Vorgang, +) from wce_crm.logging import logger_back as logger -from wce_crm.types import CompanyInfo, ContactPersonInfo, InitRecType, MainPageEntry +from wce_crm.types import ( + CompanyInfo, + CompanyProfileConsultationEntry, + CompanyProfileConsultations, + ConsultingType, + ContactPersonInfo, + InitRecType, + MainPageEntry, +) if TYPE_CHECKING: from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId @@ -393,6 +404,59 @@ def page_consulting_from_db( return consultation_data +# // consulting page interaction +def companyprofile_page_get_consultations( + un_id: RecId, +) -> CompanyProfileConsultations: + logger.debug("[Call backend] _companyprofile_page_get_consultations") + stmt = sql.select( + db.beratung_vorgang.c.vorgang_id, + db.beratung_vorgang.c.aktualisiert, + db.beratung_vorgang.c.titel, + db.beratung_vorgang.c.beratungs_typ, + ).where(db.beratung_vorgang.c.un_id == un_id) + + with db.ENGINE.connect() as conn: + res = conn.execute(stmt) + + cons_entries_pauschal: list[CompanyProfileConsultationEntry] = [] + cons_entries_individual: list[CompanyProfileConsultationEntry] = [] + + for entry in res.mappings(): + cons_id = entry["vorgang_id"] + assert cons_id, "no VorgangID defined" + + datetime_updated = cast(datetime.datetime, entry["aktualisiert"]) + datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST) + + base_title = entry["titel"] + + _cons_type = entry["beratungs_typ"] + cons_type = ConsultingType(_cons_type) + + con_entry = CompanyProfileConsultationEntry( + cons_id=cons_id, + title=base_title, + date_updated=datetime_updated, + cons_type=cons_type, + ) + + if cons_type is ConsultingType.PAUSCHAL: + cons_entries_pauschal.append(con_entry) + elif cons_type is ConsultingType.INDIVIDUAL: + cons_entries_individual.append(con_entry) + else: + raise TypeError(f"Unknown consulting type: {_cons_type}") + + cons_entries_pauschal.sort(key=lambda x: x.date_updated, reverse=True) + cons_entries_individual.sort(key=lambda x: x.date_updated, reverse=True) + + return CompanyProfileConsultations( + pauschal=cons_entries_pauschal, + individual=cons_entries_individual, + ) + + # // main page interaction def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 3c5772c..05bc1f1 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -1,9 +1,11 @@ from __future__ import annotations +import copy +import dataclasses as dc import datetime -import enum import json -from typing import Annotated, Any, Final, Literal +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar from pydantic import ( AwareDatetime, @@ -16,8 +18,16 @@ from pydantic import ( ) from pydantic_core import ErrorDetails -ValidAge = Annotated[int, Field(ge=0, le=99)] +from wce_crm.form_defs import FormField +from wce_crm.types import ConsId, ConsultingType, RecId, UserId +if TYPE_CHECKING: + from wce_crm.gui import Page_Consulting_ConsultingSession # noqa: F401 + +ValidAge = Annotated[int, Field(ge=0, le=99)] +COLUMN_SEP: Final[str] = "__" +M = TypeVar("M", bound="Module") +S = TypeVar("S", bound="PageState") # // validation translation GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = { @@ -64,9 +74,204 @@ def _parse_json(value: Any) -> str: raise TypeError -COLUMN_SEP: Final[str] = "__" +# // GUI states and types +def set_page_state( + target: S, + source: S, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and atrget must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + for field in dc.fields(source): + if field.name == "child_modules": + continue + + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) +class WrapperModule(Protocol): + def _sync_state_to_GUI(self) -> None: + """build states for all child modules and use their `load_state` method + to initialise them with the new data + """ + ... + + def _sync_GUI_to_state(self) -> None: + """call `get_state` method on all child modules and assign respective + properties to state of parent module + """ + ... + + def load_state(self, new_state: Any) -> None: ... + + def get_state(self) -> Any: + """should always be a simple call to `_sync_GUI_to_state` and then returning + the module's state + + Returns + ------- + Any + state of the respective module + """ + ... + + +class Module(WrapperModule, Protocol): + def validate(self) -> list[str]: + """should be a list of field names which could not be validated successfully, + propagates through: parent components must call this on all child modules and extend + their own error list with the return value + + Returns + ------- + list[str] + list of error fields (form field label) + """ + ... + + def save_data(self) -> None: ... + + +class AutoFormInsert(Protocol): + def __call__( + self, + data: dict[str, Any], + ) -> RecId: ... + + +class AutoFormUpdate(Protocol): + def __call__( + self, + id_: int, + data: dict[str, Any], + ) -> None: ... + + +class AutoFormGet(Protocol): + def __call__( + self, + id_: int, + ) -> dict[str, Any]: ... + + +class AutoFormDelete(Protocol): + def __call__( + self, + id_: int, + ) -> None: ... + + +@dc.dataclass(slots=True) +class Session: + user_id: UserId + user_name: str + + +@dc.dataclass(slots=True) +class PageState(Generic[M]): + child_modules: list[M] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_MainPage_State(PageState[Module]): + session: Session + row_count: int = 0 + + +@dc.dataclass(slots=True, kw_only=True) +class Page_NewInitRec_State(PageState[Module]): + session: Session + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecCompany_State(PageState[Module]): + session: Session + un_id: RecId | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class AutoFormConfig: + model: type[FlatBaseModel] + data_insert: AutoFormInsert + data_update: AutoFormUpdate + data_get: AutoFormGet + data_delete: AutoFormDelete + form_fields: Sequence[FormField] + ignored_keys: Iterable[str] = tuple() + add_buttons: bool = True + + +@dc.dataclass(slots=True, kw_only=True) +class AutoForm_State(PageState[Module]): + session: Session + cfg: AutoFormConfig + rec_id: RecId | None = None + form_data: dict[str, Any] | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecPerson_State(PageState[Module]): + session: Session + pers_id: RecId | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_State(PageState[Module]): + session: Session + vorgang_id: ConsId | None + un_id: RecId | None = None + pers_id: RecId | None = None + titel: str = "TEST-TITEL" # add title later to GUI or build it in the background + beratungs_typ: ConsultingType + cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( + default_factory=list + ) + ist_geloescht: bool = False + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_ConsultingSession_State(PageState[Module]): + session: Session + nutzer_id: UserId # must be set with the session (always known) + nutzer_name: str # must be set with the session (always known) + beratung_id: int | None # known and not known entries + zeitstempel: datetime.datetime | None = None + # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) + ansprechpartner: str | None = None + kommunikationsweg: str | None = None + thema_crm_matrix: str | None = ( + None # TODO should be linked to something, target unclear (need specification by customer) + ) + anmerkungen: str | None = None + rueckmeldung: str | None = None + ist_geloescht: bool = False + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): + session: Session + row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_CompanyProfile_State(PageState[Module]): + session: Session + rec_id: RecId | None = None + + +# // Pydantic models class FlatBaseModel(BaseModel): """ Optimised Pydantic base class, which parses JSON strings and column @@ -347,11 +552,6 @@ class Grunderfassung_Sprachen(BaseModel): # ** Consulting -class ConsultingType(enum.StrEnum): - PAUSCHAL = enum.auto() - INDIVIDUAL = enum.auto() - - class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index d5ffdc3..1b279d9 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -7,13 +7,12 @@ import re import sys import traceback from collections import defaultdict -from collections.abc import Container, Iterable, Sequence +from collections.abc import Callable, Container, Iterable, Sequence from pprint import pformat from typing import ( TYPE_CHECKING, Any, Final, - Generic, Literal, Protocol, TypeAlias, @@ -80,12 +79,22 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, + AutoForm_State, + AutoFormConfig, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, - ConsultingType, - FlatBaseModel, Grunderfassung_Personen, Grunderfassung_Unternehmen, + Page_CompanyProfile_State, + Page_Consulting_ConsultingSession_State, + Page_Consulting_State, + Page_Consulting_Table_State, + Page_InitRecCompany_State, + Page_InitRecPerson_State, + Page_MainPage_State, + Page_NewInitRec_State, + Session, + set_page_state, translate_pydantic_errors, ) from wce_crm.form_defs import ( @@ -101,16 +110,17 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) +from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType if TYPE_CHECKING: - from wce_crm.types import ConsId, ExtMaId, RecId, UserId + from wce_crm.data_models import AutoForm_State, Module, WrapperModule + from wce_crm.types import ConsId, ExtMaId K = TypeVar("K") V = TypeVar("V") T = TypeVar("T") W = TypeVar("W", bound="QWidget") -M = TypeVar("M", bound="Module") -S = TypeVar("S", bound="PageState") + DEBUG: bool = True DEBUG_SEARCH_WIDGET: bool = False @@ -137,6 +147,60 @@ QSS = """ *[styleClass="stempel"]:focus { border: 1px dashed #cbd5e1; } + +QPushButton[styleClass="addButton"] { + background-color: #007acc; + color: white; + border: none; + border-radius: 6px; + padding: 8px 15px; + font-weight: bold; +} + +QPushButton[styleClass="addButton"]:hover { + background-color: #0098ff; +} + +QPushButton[styleClass="outlineAddButton"] { + background-color: transparent; + color: #007acc; + border: 1px solid #007acc; + border-radius: 6px; + padding: 5px 12px; + font-weight: bold; +} + +QPushButton[styleClass="outlineAddButton"]:hover { + background-color: #007acc; + color: white; +} + +QPushButton[styleClass="softAddButton"] { + background-color: #e0f2fe; + color: #0369a1; + border: none; + border-radius: 6px; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="softAddButton"]:hover { + background-color: #bae6fd; + color: #0369a1; +} + +QPushButton[styleClass="flatAddButton"] { + background-color: transparent; + color: #475569; + border: none; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="flatAddButton"]:hover { + background-color: #f1f5f9; + color: #007acc; +} """ DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]") @@ -199,78 +263,6 @@ class CustomForm(Protocol): def validate_form_data(self) -> list[str]: ... -class WrapperModule(Protocol): - def _sync_state_to_GUI(self) -> None: - """build states for all child modules and use their `load_state` method - to initialise them with the new data - """ - ... - - def _sync_GUI_to_state(self) -> None: - """call `get_state` method on all child modules and assign respective - properties to state of parent module - """ - ... - - def load_state(self, new_state: Any) -> None: ... - - def get_state(self) -> Any: - """should always be a simple call to `_sync_GUI_to_state` and then returning - the module's state - - Returns - ------- - Any - state of the respective module - """ - ... - - -class Module(WrapperModule, Protocol): - def validate(self) -> list[str]: - """should be a list of field names which could not be validated successfully, - propagates through: parent components must call this on all child modules and extend - their own error list with the return value - - Returns - ------- - list[str] - list of error fields (form field label) - """ - ... - - def save_data(self) -> None: ... - - -class AutoFormInsert(Protocol): - def __call__( - self, - data: dict[str, Any], - ) -> backend.RecId: ... - - -class AutoFormUpdate(Protocol): - def __call__( - self, - id_: int, - data: dict[str, Any], - ) -> None: ... - - -class AutoFormGet(Protocol): - def __call__( - self, - id_: int, - ) -> dict[str, Any]: ... - - -class AutoFormDelete(Protocol): - def __call__( - self, - id_: int, - ) -> None: ... - - class CustomWidget(QWidget): def __init__( self, @@ -292,104 +284,6 @@ class CustomWidget(QWidget): def validate_form_data(self) -> list[str]: ... -@dc.dataclass(slots=True) -class Session: - user_id: UserId - user_name: str - - -@dc.dataclass(slots=True) -class PageState(Generic[M]): - child_modules: list[M] = dc.field(default_factory=list) - - -@dc.dataclass(slots=True, kw_only=True) -class Page_MainPage_State(PageState[Module]): - session: Session - row_count: int = 0 - - -@dc.dataclass(slots=True, kw_only=True) -class Page_NewInitRec_State(PageState[Module]): - session: Session - - -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecCompany_State(PageState[Module]): - session: Session - un_id: RecId | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class AutoFormConfig: - model: type[FlatBaseModel] - data_insert: AutoFormInsert - data_update: AutoFormUpdate - data_get: AutoFormGet - data_delete: AutoFormDelete - form_fields: Sequence[FormField] - ignored_keys: Iterable[str] = tuple() - add_buttons: bool = True - - -@dc.dataclass(slots=True, kw_only=True) -class AutoForm_State(PageState[Module]): - session: Session - cfg: AutoFormConfig - rec_id: RecId | None = None - form_data: dict[str, Any] | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecPerson_State(PageState[Module]): - session: Session - pers_id: RecId | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_State(PageState[Module]): - session: Session - vorgang_id: ConsId | None - un_id: RecId | None = None - pers_id: RecId | None = None - titel: str = "TEST-TITEL" # add title later to GUI or build it in the background - beratungs_typ: ConsultingType - cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( - default_factory=list - ) - ist_geloescht: bool = False - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_ConsultingSession_State(PageState[Module]): - session: Session - nutzer_id: UserId # must be set with the session (always known) - nutzer_name: str # must be set with the session (always known) - beratung_id: int | None # known and not known entries - zeitstempel: datetime.datetime | None = None - # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) - ansprechpartner: str | None = None - kommunikationsweg: str | None = None - thema_crm_matrix: str | None = ( - None # TODO should be linked to something, target unclear (need specification by customer) - ) - anmerkungen: str | None = None - rueckmeldung: str | None = None - ist_geloescht: bool = False - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): - session: Session - row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) - - -@dc.dataclass(slots=True, kw_only=True) -class Page_CompanyProfile_State(PageState[Module]): - session: Session - rec_id: RecId | None = None - - def _add_widget_to_layout( form_field: FormField[W], widget: QWidget, @@ -2581,25 +2475,43 @@ class NoScrollFilter(QObject): return super().eventFilter(obj, event) -class MainPage_ClickableCell(QFrame): - """cell in the table on the startup screen""" - - clicked = Signal(types.MainPageEntry) +class HeaderCell(QLabel): + """basic header for table like objects""" + + def __init__( + self, + text: str, + font_size: int = 12, + ): + super().__init__(text) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setStyleSheet(f""" + HeaderCell {{ + background-color: #e2e8f0; + color: #475569; + font-weight: bold; + padding: 10px; + border-radius: 6px; + font-size: {font_size}px; + }} + """) + + +class ClickableCell(QFrame): + """basic cell which can be clicked""" def __init__( self, text: str, - data_record: types.MainPageEntry, ): super().__init__() - self.data_record = data_record self.setStyleSheet(""" - MainPage_ClickableCell { + ClickableCell { background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; } - MainPage_ClickableCell:hover { + ClickableCell:hover { background-color: #eff6ff; border: 1px solid #60a5fa; } @@ -2610,29 +2522,41 @@ class MainPage_ClickableCell(QFrame): label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) + +class CompanyProfile_Consultation_ClickableCell(ClickableCell): + """cell on the company profile for consultations""" + + clicked = Signal(CompanyProfileConsultationEntry) + + def __init__( + self, + text: str, + data_record: CompanyProfileConsultationEntry, + ): + super().__init__(text=text) + self.data_record = data_record + def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data_record) -class MainPage_HeaderCell(QLabel): +class MainPage_ClickableCell(ClickableCell): + """cell in the table on the startup screen""" + + clicked = Signal(types.MainPageEntry) + def __init__( self, text: str, - font_size: int = 12, + data_record: types.MainPageEntry, ): - super().__init__(text) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setStyleSheet(f""" - MainPage_HeaderCell {{ - background-color: #e2e8f0; - color: #475569; - font-weight: bold; - padding: 10px; - border-radius: 6px; - font-size: {font_size}px; - }} - """) + super().__init__(text=text) + self.data_record = data_record + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.data_record) class Page_NewInitRec(QWidget): @@ -2867,6 +2791,9 @@ class Page_InitRecCompany(QWidget): ) def _request_company_profile(self) -> None: + assert self.STATE.un_id is not None, ( + "requested company profile for a company which is not known (no ID)" + ) req_state = Page_CompanyProfile_State( session=self.STATE.session, rec_id=self.STATE.un_id, @@ -3148,6 +3075,25 @@ class Page_CompanyProfile(QWidget): change_title_btn.clicked.connect(self.set_company_name) container_layout.addLayout(layout_change_title) + btn_dummy_pauschal = QPushButton("Dummy Pauschalberatung hinzufügen") + btn_dummy_pauschal.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.PAUSCHAL) + ) + container_layout.addWidget(btn_dummy_pauschal) + btn_dummy_inidividual = QPushButton("Dummy Individualberatung hinzufügen") + btn_dummy_inidividual.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.INDIVIDUAL) + ) + container_layout.addWidget(btn_dummy_inidividual) + + btn_clear_cons = QPushButton("Beratungen leeren") + btn_clear_cons.clicked.connect(self._clear_consultations) + container_layout.addWidget(btn_clear_cons) + + btn_update_cons = QPushButton("Beratungen aktualisieren") + btn_update_cons.clicked.connect(self._update_consultations) + container_layout.addWidget(btn_update_cons) + separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -3155,7 +3101,7 @@ class Page_CompanyProfile(QWidget): container_layout.addSpacing(30) - self.title_company_name = MainPage_HeaderCell("Unternehmen: n.v.", font_size=16) + self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) container_layout.addWidget(self.title_company_name) container_layout.addSpacing(20) @@ -3171,7 +3117,7 @@ class Page_CompanyProfile(QWidget): left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) left_layout = QVBoxLayout(left_frame) main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) - master_data_label = MainPage_HeaderCell("Stammdaten & Vermerke") + master_data_label = HeaderCell("Stammdaten & Vermerke") master_data_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) @@ -3193,21 +3139,23 @@ class Page_CompanyProfile(QWidget): top_right_frame.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) - top_right_layout = QVBoxLayout(top_right_frame) - common_consultancy_label = MainPage_HeaderCell("Pauschalberatung") + self.layout_cons_pauschal = QVBoxLayout(top_right_frame) + self.cons_pauschal: list[CompanyProfile_Consultation_ClickableCell] = [] + common_consultancy_label = HeaderCell("Pauschalberatung") common_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - top_right_layout.addWidget(common_consultancy_label) + self.layout_cons_pauschal.addWidget(common_consultancy_label) self.add_btn_pauschal = QPushButton("+ Hinzufügen") self.add_btn_pauschal.clicked.connect( - lambda: self._request_consulting(ConsultingType.PAUSCHAL) + lambda: self._request_new_consulting(ConsultingType.PAUSCHAL) ) self.add_btn_pauschal.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.add_btn_pauschal.setMinimumHeight(30) - top_right_layout.addWidget(self.add_btn_pauschal) + self.add_btn_pauschal.setProperty("styleClass", "flatAddButton") + self.layout_cons_pauschal.addWidget(self.add_btn_pauschal) # bottom right area bottom_right_frame = QFrame() @@ -3215,21 +3163,23 @@ class Page_CompanyProfile(QWidget): bottom_right_frame.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) - bottom_right_layout = QVBoxLayout(bottom_right_frame) - individual_consultancy_label = MainPage_HeaderCell("Individualberatung") + self.layout_cons_indiv = QVBoxLayout(bottom_right_frame) + self.cons_indiv: list[CompanyProfile_Consultation_ClickableCell] = [] + individual_consultancy_label = HeaderCell("Individualberatung") individual_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - bottom_right_layout.addWidget(individual_consultancy_label) + self.layout_cons_indiv.addWidget(individual_consultancy_label) self.add_btn_individual = QPushButton("+ Hinzufügen") self.add_btn_individual.clicked.connect( - lambda: self._request_consulting(ConsultingType.INDIVIDUAL) + lambda: self._request_new_consulting(ConsultingType.INDIVIDUAL) ) self.add_btn_individual.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.add_btn_individual.setMinimumHeight(30) - bottom_right_layout.addWidget(self.add_btn_individual) + self.add_btn_individual.setProperty("styleClass", "flatAddButton") + self.layout_cons_indiv.addWidget(self.add_btn_individual) right_column_layout.addWidget(top_right_frame) right_column_layout.addWidget(bottom_right_frame) @@ -3270,15 +3220,37 @@ class Page_CompanyProfile(QWidget): # } # """) + def _debug_consulting_dummy( + self, + type: ConsultingType, + ): + if type is ConsultingType.PAUSCHAL: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Pauschal", + None, # type: ignore + ) + target_layout = self.layout_cons_pauschal + self.cons_pauschal.append(dummy_click_cell) + else: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Individual", + None, # type: ignore + ) + target_layout = self.layout_cons_indiv + self.cons_indiv.append(dummy_click_cell) + + target_idx = target_layout.count() - 1 + target_layout.insertWidget(target_idx, dummy_click_cell) + def _request_initrec_company(self) -> None: req_state = Page_InitRecCompany_State( session=self.STATE.session, un_id=self.STATE.rec_id, ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + logger_gui.debug("[Page Company Profile] State to call: %s", req_state) self.back_requested.emit(req_state) - def _request_consulting( + def _request_new_consulting( self, cons_type: ConsultingType, ) -> None: @@ -3293,6 +3265,62 @@ class Page_CompanyProfile(QWidget): ) self.consulting_requested.emit(new_state) + def _request_existing_consulting( + self, + data: CompanyProfileConsultationEntry, + ) -> None: + assert self.STATE.rec_id is not None, ( + "tried to call consulting from comp profile without set ID" + ) + new_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=data.cons_id, + un_id=self.STATE.rec_id, + beratungs_typ=data.cons_type, + ) + self.consulting_requested.emit(new_state) + + def _clear_consultations(self) -> None: + logger_gui.debug("[Page Company Profile] Clear consultations...") + + while self.cons_pauschal: + widget = self.cons_pauschal[0] + self.layout_cons_pauschal.removeWidget(widget) + self.cons_pauschal.remove(widget) + widget.deleteLater() + + while self.cons_indiv: + widget = self.cons_indiv[0] + self.layout_cons_indiv.removeWidget(widget) + self.cons_indiv.remove(widget) + widget.deleteLater() + + def _update_consultations(self) -> None: + assert self.STATE.rec_id is not None, ( + "cannot update consultations if RecID is not set" + ) + consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.rec_id) + + self._clear_consultations() + + for entry in consultations.pauschal: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_pauschal.count() - 1 + self.layout_cons_pauschal.insertWidget(target_idx, click_cell) + self.cons_pauschal.append(click_cell) + + for entry in consultations.individual: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_indiv.count() - 1 + self.layout_cons_indiv.insertWidget(target_idx, click_cell) + self.cons_indiv.append(click_cell) + def _disable_add_btns(self) -> None: self.add_btn_pauschal.setEnabled(False) self.add_btn_individual.setEnabled(False) @@ -3797,13 +3825,18 @@ class Page_Consulting(QWidget): self, text: str, button_text: str, + button_func: Callable[[], None] | None = None, ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + banner = InfoBanner( text=text, button_text=button_text, ) + if button_func is not None: + banner.btn_clicked.connect(button_func) self.info_banners.append(banner) - banner.link_clicked.connect(self._open_linking_dialogue) self.info_banners_layout.addWidget(banner) def _sync_state_to_GUI(self) -> None: @@ -3824,23 +3857,30 @@ class Page_Consulting(QWidget): # info banners needed self._clear_info_banners() - if self.STATE.un_id is None and self.STATE.pers_id is None: - # link missing + if self.STATE.vorgang_id is None: self._add_linking_banner( - text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", - button_text="Jetzt verknüpfen", - ) - elif ( - self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL - and self.STATE.pers_id is None - ): - self._add_linking_banner( - text=( - "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " - "einer Person fehlt." - ), - button_text="Jetzt verknüpfen", + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", ) + else: + if self.STATE.un_id is None and self.STATE.pers_id is None: + self._add_linking_banner( + text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) + elif ( + self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL + and self.STATE.pers_id is None + ): + self._add_linking_banner( + text=( + "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " + "einer Person fehlt." + ), + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) # child modules # call to child modules' syncing method not needed because these are initialised by @@ -4124,6 +4164,7 @@ class Page_Consulting_ConsultingSession(QWidget): layout, widget_registry=self.widget_registry, ) + self.timestamp.set_pydatetime(datetime.datetime.now(TIMEZONE_CEST)) contact_def = FormField[QLineEdit]( "Kontaktperson", @@ -4302,24 +4343,10 @@ class Page_Consulting_Table(QWidget): # --- BUTTON (ADD) --- self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen") self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) - self.btn_add.setObjectName("BlueAddButton") # ID vergeben + self.btn_add.setProperty("styleClass", "addButton") main_layout.addWidget(self.btn_add) self.btn_add.clicked.connect(lambda: self.add_row(None, False)) - self.btn_add.setStyleSheet(""" - #BlueAddButton { - background-color: #007acc; - color: white; - border: none; - border-radius: 6px; - padding: 8px 15px; - font-weight: bold; - } - #BlueAddButton:hover { - background-color: #0098ff; - } - """) - # --- HEADER --- header_widget = QWidget() header_layout = QHBoxLayout(header_widget) @@ -4547,7 +4574,7 @@ class Page_Consulting_Table(QWidget): class InfoBanner(QFrame): - link_clicked = Signal() + btn_clicked = Signal() def __init__( self, @@ -4582,43 +4609,18 @@ class InfoBanner(QFrame): layout.setContentsMargins(15, 10, 15, 10) self.label = QLabel(text) - self.button = QPushButton(button_text) layout.addWidget(self.label) layout.addStretch() - layout.addWidget(self.button) - self.button.clicked.connect(self.link_clicked.emit) + if button_text: + self.button = QPushButton(button_text) + layout.addWidget(self.button) + self.button.clicked.connect(self.btn_clicked.emit) #################################################################### -def set_page_state( - target: S, - source: S, - deep: bool = False, -) -> None: - """ - transfer all field values generically from 'source' to 'target' - """ - if not (dc.is_dataclass(target) and dc.is_dataclass(source)): - raise TypeError("Both source and atrget must be dataclasses") - - if type(target) is not type(source): - raise TypeError("Both source and target must have the same type") - - for field in dc.fields(source): - if field.name == "child_modules": - continue - - new_value = getattr(source, field.name) - - if deep: - new_value = copy.deepcopy(new_value) - - setattr(target, field.name, new_value) - - def clear_layout( layout: QLayout | None, ) -> None: @@ -4782,7 +4784,7 @@ class MainWindow(QMainWindow): "Datum", ] for col_idx, title in enumerate(headers): - self.header_grid.addWidget(MainPage_HeaderCell(title), self.current_row, col_idx) + self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) self.update_grid() diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index eb8e648..a4c0715 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: import polars as pl + UserId: TypeAlias = int RecId: TypeAlias = int ConsId: TypeAlias = int @@ -22,6 +23,11 @@ class InitRecType(enum.IntEnum): PERSON = enum.auto() +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIDUAL = enum.auto() + + # // database interaction data structures # ** externals class CompanyInfo(TypedDict): @@ -83,3 +89,17 @@ class MainPageEntry: display_name: str Metadaten_aktualisierung: datetime.datetime type: InitRecType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultationEntry: + title: str + cons_id: ConsId + date_updated: datetime.datetime + cons_type: ConsultingType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultations: + pauschal: list[CompanyProfileConsultationEntry] + individual: list[CompanyProfileConsultationEntry] -- 2.34.1 From 3bbed9404891a9554ffb0e895a0879cedf92571c Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 15:05:53 +0200 Subject: [PATCH 34/74] enable/disable page modification --- src/wce_crm/data_models.py | 14 +- src/wce_crm/gui.py | 725 +++++++++++++++++++++++++++++-------- 2 files changed, 582 insertions(+), 157 deletions(-) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 05bc1f1..f4e2dce 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -102,13 +102,13 @@ def set_page_state( class WrapperModule(Protocol): - def _sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI(self, *args, **kwargs) -> None: """build states for all child modules and use their `load_state` method to initialise them with the new data """ ... - def _sync_GUI_to_state(self) -> None: + def _sync_GUI_to_state(self, *args, **kwargs) -> None: """call `get_state` method on all child modules and assign respective properties to state of parent module """ @@ -127,6 +127,10 @@ class WrapperModule(Protocol): """ ... + def lock(self) -> None: ... + + def unlock(self) -> None: ... + class Module(WrapperModule, Protocol): def validate(self) -> list[str]: @@ -199,6 +203,7 @@ class Page_NewInitRec_State(PageState[Module]): class Page_InitRecCompany_State(PageState[Module]): session: Session un_id: RecId | None = None + locked: bool @dc.dataclass(slots=True, kw_only=True) @@ -219,12 +224,14 @@ class AutoForm_State(PageState[Module]): cfg: AutoFormConfig rec_id: RecId | None = None form_data: dict[str, Any] | None = None + locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_InitRecPerson_State(PageState[Module]): session: Session pers_id: RecId | None = None + locked: bool @dc.dataclass(slots=True, kw_only=True) @@ -239,6 +246,7 @@ class Page_Consulting_State(PageState[Module]): default_factory=list ) ist_geloescht: bool = False + locked: bool @dc.dataclass(slots=True, kw_only=True) @@ -257,12 +265,14 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): anmerkungen: str | None = None rueckmeldung: str | None = None ist_geloescht: bool = False + locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): session: Session row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) + locked: bool @dc.dataclass(slots=True, kw_only=True) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 1b279d9..026ae14 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -30,6 +30,7 @@ from PySide6.QtCore import ( QDate, QDateTime, QEvent, + QKeyCombination, QLibraryInfo, QLocale, QObject, @@ -40,7 +41,7 @@ from PySide6.QtCore import ( Signal, qInstallMessageHandler, ) -from PySide6.QtGui import QAction, QFont +from PySide6.QtGui import QAction, QFont, QKeySequence from PySide6.QtWidgets import ( QAbstractItemView, QApplication, @@ -145,7 +146,7 @@ QSS = """ } *[styleClass="stempel"]:focus { - border: 1px dashed #cbd5e1; + border: 1px solid #cbd5e1; } QPushButton[styleClass="addButton"] { @@ -161,6 +162,11 @@ QPushButton[styleClass="addButton"]:hover { background-color: #0098ff; } +QPushButton[styleClass="addButton"]:disabled { + background-color: #cbd5e1; + color: #94a3b8; +} + QPushButton[styleClass="outlineAddButton"] { background-color: transparent; color: #007acc; @@ -175,6 +181,13 @@ QPushButton[styleClass="outlineAddButton"]:hover { color: white; } +QPushButton[styleClass="outlineAddButton"]:disabled { + /* background-color: #cbd5e1; */ + background-color: transparent; + color: #94a3b8; + border: none; +} + QPushButton[styleClass="softAddButton"] { background-color: #e0f2fe; color: #0369a1; @@ -206,6 +219,7 @@ DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]") DATETIME_FMT: Final[str] = "%d.%m.%Y %H:%M:%S" DATE_FMT: Final[str] = "%d.%m.%Y" +DEFAULT_READONLY_STYLECLASS: Final[str] = "stempel" def merge_dicts_to_lists( @@ -254,16 +268,20 @@ def pformat_registry(widget_registry: WidgetRegistry) -> str: class CustomForm(Protocol): - def get_form_data(self) -> dict[str, Any]: ... + def get_form_data(self) -> dict[str, Any] | list[dict[str, Any]]: ... def set_form_data(self, data: Any) -> None: ... - def reset_form(self) -> None: ... + def reset(self) -> None: ... - def validate_form_data(self) -> list[str]: ... + def validate(self) -> list[str]: ... + + def lock(self) -> None: ... + + def unlock(self) -> None: ... -class CustomWidget(QWidget): +class CustomFormWidget(QWidget): def __init__( self, form_fields: Sequence[FormField], @@ -279,9 +297,13 @@ class CustomWidget(QWidget): def set_form_data(self, data: Any) -> None: ... - def reset_form(self) -> None: ... + def reset(self) -> None: ... - def validate_form_data(self) -> list[str]: ... + def validate(self) -> list[str]: ... + + def lock(self) -> None: ... + + def unlock(self) -> None: ... def _add_widget_to_layout( @@ -636,11 +658,11 @@ def reset_form( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, - CustomWidget, + CustomFormWidget, ), ): # custom widget classes manage their widgets on their own - widget.reset_form() + widget.reset() widget.setStyleSheet("") @@ -781,7 +803,7 @@ def get_widget_value( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, - CustomWidget, + CustomFormWidget, ), ): # this is a special data structure with some assumptions of the widget's internals @@ -936,10 +958,10 @@ def validate_form_data( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, - CustomWidget, + CustomFormWidget, ), ): - errors_widget = widget.validate_form_data() + errors_widget = widget.validate() if not errors_widget: continue errors.extend(errors_widget) @@ -968,7 +990,9 @@ def validate_form_data( return errors -def validate_child_modules(modules: Iterable[Module]) -> list[str]: +def validate_child_modules( + modules: Iterable[Module], +) -> list[str]: errors: list[str] = [] for mod in modules: errors.extend(mod.validate()) @@ -976,7 +1000,87 @@ def validate_child_modules(modules: Iterable[Module]) -> list[str]: return errors -class Grunderfassung_SuchWidget(CustomWidget): +def lock_form( + widget_registry: WidgetRegistry, +) -> None: + for registry_entry in widget_registry.values(): # type: ignore + widget = registry_entry["widget"] + form_field = registry_entry["form_field"] + + if form_field.readonly: + continue + + if isinstance( + widget, + ( + QLineEdit, + QPlainTextEdit, + QDateEdit, + FlexibleDateInput, + FlexibleDateTimeInput, + ), + ): + widget.setReadOnly(True) + elif isinstance(widget, QComboBox): + widget.setEnabled(False) + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomFormWidget, + ), + ): + widget.lock() + + widget.setProperty("styleClass", "stempel") + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +def unlock_form( + widget_registry: WidgetRegistry, +) -> None: + for registry_entry in widget_registry.values(): # type: ignore + widget = registry_entry["widget"] + form_field = registry_entry["form_field"] + + if form_field.readonly: + continue + + if isinstance( + widget, + ( + QLineEdit, + QPlainTextEdit, + QDateEdit, + FlexibleDateInput, + FlexibleDateTimeInput, + ), + ): + widget.setReadOnly(False) + elif isinstance(widget, QComboBox): + widget.setEnabled(True) + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomFormWidget, + ), + ): + widget.unlock() + + widget.setProperty("styleClass", "") + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +class Grunderfassung_SuchWidget(CustomFormWidget): def __init__( self, form_fields: Sequence[FormField], @@ -1065,7 +1169,7 @@ class Grunderfassung_SuchWidget(CustomWidget): button.clicked.connect(self.print_registry) main_layout.addWidget(button) button = QPushButton("Reset form") - button.clicked.connect(self.reset_form) + button.clicked.connect(self.reset) main_layout.addWidget(button) button = QPushButton("Get form data") button.clicked.connect(self.get_form_data) @@ -1074,7 +1178,7 @@ class Grunderfassung_SuchWidget(CustomWidget): button.clicked.connect(self.set_form_data) main_layout.addWidget(button) button = QPushButton("Validate") - button.clicked.connect(self.validate_form_data) + button.clicked.connect(self.validate) main_layout.addWidget(button) self.update_company_data() @@ -1170,13 +1274,13 @@ class Grunderfassung_SuchWidget(CustomWidget): self.fill_out_person(data) @override - def reset_form(self) -> None: + def reset(self) -> None: reset_form(self.widget_registry) self._clear_company_fields() self._clear_person_fields() @override - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) return errors @@ -1199,6 +1303,18 @@ class Grunderfassung_SuchWidget(CustomWidget): set_form_data(self.widget_registry, data, filter_keys=self.DATA_EXPORT_FIELDS) + @override + def lock( + self, + ) -> None: + lock_form(self.widget_registry) + + @override + def unlock( + self, + ) -> None: + unlock_form(self.widget_registry) + def enhanced_label( base_label: str, @@ -1338,6 +1454,28 @@ class AutoForm(QWidget): self.main_layout.addSpacing(10) + # buttons (save and reset) + self.add_buttons = self.cfg.add_buttons + self.edit_buttons: EditButtons | None = None + if self.add_buttons: + self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True) + modify_btn = self.edit_buttons.modify_button + assert modify_btn + self.modify_btn = modify_btn + reset_btn = self.edit_buttons.reset_btn + assert reset_btn + self.reset_btn = reset_btn + self.save_btn = self.edit_buttons.save_btn + self.delete_btn = self.edit_buttons.delete_btn + + self.modify_btn.modify_activated.connect(self.unlock_form) + self.modify_btn.modify_deactivated.connect(self.lock_form) + self.save_btn.clicked.connect(self.save_data) + self.reset_btn.clicked.connect(self.reset_form) + self.delete_btn.clicked.connect(self._delete_data) + + self.main_layout.addWidget(self.edit_buttons) + self.top_level_form_layout = QFormLayout() self.main_layout.addLayout(self.top_level_form_layout) self.top_level_form_layout.setSpacing(10) @@ -1351,23 +1489,7 @@ class AutoForm(QWidget): self.widget_registry, ) - # buttons (save and reset) - self.add_buttons = self.cfg.add_buttons - self.debug_form_data: dict[str, Any] = {} - if self.add_buttons: - self.edit_buttons = EditButtons(add_reset_btn=True) - reset_btn = self.edit_buttons.reset_btn - assert reset_btn - self.save_btn = self.edit_buttons.save_btn - self.reset_btn = reset_btn - self.delete_btn = self.edit_buttons.delete_btn - - self.save_btn.clicked.connect(self.save_data) - self.reset_btn.clicked.connect(self.reset_form) - self.delete_btn.clicked.connect(self._delete_data) - - self.main_layout.addWidget(self.edit_buttons) logger_auto_form.debug( "[Auto-Form] Initialised Auto Form Widget. Registry:%s", @@ -1386,6 +1508,8 @@ class AutoForm(QWidget): logger_gui.debug("[Auto-Form] Set index to %s, new state: %s", index, self.STATE) def _disable_save(self) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None self.save_btn.setEnabled(False) self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) @@ -1393,6 +1517,8 @@ class AutoForm(QWidget): self, timeout: int = 3000, ) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) @@ -1424,6 +1550,8 @@ class AutoForm(QWidget): if lookup_id is not None: logger_auto_form.debug("Load from DB:") loaded_data = self.cfg.data_get(lookup_id) + # TODO add path to avoid unnecessary loading (where the same ID is loaded + # twice in a row) -- use an internal marker (not state-related) else: logger_auto_form.debug("[Auto-Form] Loading: Lookup ID NULL. Do nothing!") return @@ -1447,6 +1575,14 @@ class AutoForm(QWidget): self.STATE.form_data = None self._activate_delete() + def lock_form(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock_form(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") + def _get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) logger_auto_form.debug("[Auto-Form] Call get form data:") @@ -1467,8 +1603,22 @@ class AutoForm(QWidget): set_form_data(self.widget_registry, data) - def _sync_state_to_GUI(self) -> None: - self._load_data(self.STATE.rec_id) + def _sync_state_to_GUI( + self, + section: Literal["form", "locking"] | None, + ) -> None: + if section is None or section == "form": + self._load_data(self.STATE.rec_id) + + if section is None or section == "locking": + if self.STATE.locked: + if self.edit_buttons is not None: + self.edit_buttons.set_modify_state(False) + lock_form(self.widget_registry) + else: + if self.edit_buttons is not None: + self.edit_buttons.set_modify_state(True) + unlock_form(self.widget_registry) def _sync_GUI_to_state(self) -> None: form_data = self._get_form_data() @@ -1484,7 +1634,7 @@ class AutoForm(QWidget): new_state: AutoForm_State, ) -> None: set_page_state(self.STATE, new_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def get_state(self) -> AutoForm_State: self._sync_GUI_to_state() @@ -1629,6 +1779,7 @@ class FlexibleDateInput(QWidget): def setReadOnly( self, read_only: bool, + style_class_readonly: str = DEFAULT_READONLY_STYLECLASS, ): self._read_only = read_only self.line_edit.setReadOnly(read_only) @@ -1648,10 +1799,15 @@ class FlexibleDateInput(QWidget): ) cleaned_date = current_text if pure else "" self.line_edit.setText(cleaned_date) - self.line_edit.setProperty("styleClass", "stempel") + self.line_edit.setProperty("styleClass", style_class_readonly) else: self.line_edit.setInputMask("99.99.9999;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ") + self.line_edit.setProperty("styleClass", "") + + self.line_edit.style().unpolish(self.line_edit) + self.line_edit.style().polish(self.line_edit) + self.line_edit.update() def _validate_input( self, @@ -1821,6 +1977,7 @@ class FlexibleDateTimeInput(QWidget): def setReadOnly( self, read_only: bool, + style_class_readonly: str = DEFAULT_READONLY_STYLECLASS, ): self._read_only = read_only self.line_edit.setReadOnly(read_only) @@ -1840,10 +1997,15 @@ class FlexibleDateTimeInput(QWidget): ) cleaned_datetime = current_text if pure else "" self.line_edit.setText(cleaned_datetime) - self.line_edit.setProperty("styleClass", "stempel") + self.line_edit.setProperty("styleClass", style_class_readonly) else: self.line_edit.setInputMask("99.99.9999 99:99;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") + self.line_edit.setProperty("styleClass", "") + + self.line_edit.style().unpolish(self.line_edit) + self.line_edit.style().polish(self.line_edit) + self.line_edit.update() def _validate_input( self, @@ -1990,9 +2152,10 @@ class DynamicListWidget(QWidget): self.sub_forms: list[SubForm] = [] # button to add more sub forms self.add_btn = QPushButton("+ Hinzufügen") - self.add_btn.setStyleSheet( - "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" - ) + self.add_btn.setProperty("styleClass", "outlineAddButton") + # self.add_btn.setStyleSheet( + # "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" + # ) self.add_btn.clicked.connect(self.add_entry) self.inner_layout.addWidget(self.add_btn) @@ -2047,14 +2210,14 @@ class DynamicListWidget(QWidget): base_label=self.base_label, ) - def reset_form(self) -> None: + def reset(self) -> None: while self.sub_forms: self.remove_entry(self.sub_forms[0]) if self.add_empty_entry: self.add_entry() - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -2086,6 +2249,14 @@ class DynamicListWidget(QWidget): current_sub_form = self.sub_forms[-1] set_form_data(current_sub_form.registry, sub_form_data) + def lock(self) -> None: + lock_form(self.widget_registry) + self.add_btn.setEnabled(False) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + self.add_btn.setEnabled(True) + class DynamicDropdownWidgetNumeric(QWidget): """ @@ -2205,11 +2376,11 @@ class DynamicDropdownWidgetNumeric(QWidget): sub_forms=self.sub_forms, ) - def reset_form(self) -> None: + def reset(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -2229,8 +2400,9 @@ class DynamicDropdownWidgetNumeric(QWidget): def set_form_data( self, - sub_form_data: dict[str, Any], + data: dict[str, Any], ) -> None: + # data is data of sub forms # delete all rows while self.sub_forms: self._remove_row() @@ -2240,23 +2412,23 @@ class DynamicDropdownWidgetNumeric(QWidget): num_subforms: int = -1 for key in self.full_keys: widget = self.widget_registry[key]["widget"] - value = sub_form_data[key] + value = data[key] set_widget_value(widget, value) if value is None: num_subforms = 0 else: num_subforms = value - del sub_form_data[key] + del data[key] logger_get_data.debug(">>>>>>>>> Call set_form_data for DynamicDropdown") - logger_get_data.debug("Data before unmerge:%s", pformat(sub_form_data)) + logger_get_data.debug("Data before unmerge:%s", pformat(data)) assert len(self.sub_forms) == num_subforms if not self.sub_forms: return relevant_keys = self.sub_forms[-1].full_keys # all keys of sub forms are the same - sub_data = {key: sub_form_data[key] for key in relevant_keys} + sub_data = {key: data[key] for key in relevant_keys} logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) if not sub_data: return @@ -2265,8 +2437,18 @@ class DynamicDropdownWidgetNumeric(QWidget): logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) assert len(sub_data) == len(self.sub_forms) - for sub_form_data, sub_form in zip(sub_data, self.sub_forms): - set_form_data(sub_form.registry, sub_form_data) + for data, sub_form in zip(sub_data, self.sub_forms): + set_form_data(sub_form.registry, data) + + def lock(self) -> None: + lock_form(self.widget_registry) + for sub_form in self.sub_forms: + lock_form(sub_form.registry) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + for sub_form in self.sub_forms: + unlock_form(sub_form.registry) class DynamicDropdownWidgetOption(QWidget): @@ -2397,11 +2579,11 @@ class DynamicDropdownWidgetOption(QWidget): return whole_registry - def reset_form(self) -> None: + def reset(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -2456,6 +2638,16 @@ class DynamicDropdownWidgetOption(QWidget): set_form_data(whole_registry, data) + def lock(self) -> None: + lock_form(self.widget_registry) + for sub_form in self.sub_forms: + lock_form(sub_form.registry) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + for sub_form in self.sub_forms: + unlock_form(sub_form.registry) + class NoScrollFilter(QObject): """disables scrolling in fields""" @@ -2615,6 +2807,7 @@ class Page_NewInitRec(QWidget): req_state = Page_InitRecCompany_State( session=self.STATE.session, un_id=None, + locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.company_requested.emit(req_state) @@ -2623,6 +2816,7 @@ class Page_NewInitRec(QWidget): req_state = Page_InitRecPerson_State( session=self.STATE.session, pers_id=None, + locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.person_requested.emit(req_state) @@ -2632,6 +2826,7 @@ class Page_NewInitRec(QWidget): session=self.STATE.session, vorgang_id=None, beratungs_typ=ConsultingType.PAUSCHAL, + locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.consulting_requested.emit(req_state) @@ -2686,7 +2881,7 @@ CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( form_fields=INITREC_PERSON, ) -CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { +CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { "grunderfassung_unternehmen_suche": Grunderfassung_SuchWidget, } @@ -2723,12 +2918,6 @@ class Page_InitRecCompany(QWidget): header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) - comp_profile_btn = QPushButton("Unternehmensprofil") - comp_profile_btn.clicked.connect(self._request_company_profile) - comp_profile_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - comp_profile_btn.setMinimumWidth(250) - # comp_profile_btn.setMaximumWidth(200) - back_btn_main = QPushButton("← Zurück zur Übersicht") back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) @@ -2743,12 +2932,19 @@ class Page_InitRecCompany(QWidget): title = QLabel("Grunderfassung Unternehmen") title.setStyleSheet("font-size: 20px; font-weight: bold;") + self.comp_profile_btn = QPushButton("Unternehmensprofil") + self.comp_profile_btn.clicked.connect(self._request_company_profile) + self.comp_profile_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.comp_profile_btn.setFixedHeight(50) + header_layout.setSpacing(5) - header_layout.addWidget(comp_profile_btn) - header_layout.addSpacing(15) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) + header_layout.addSpacing(15) + header_layout.addWidget(self.comp_profile_btn) vert_layout.addWidget(header_container) # --- MAIN CONTENT --- @@ -2774,10 +2970,12 @@ class Page_InitRecCompany(QWidget): auto_form_state = AutoForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, + locked=False, ) self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_main_page.emit()) + self.auto_form.update_triggered.connect(self._enable_company_profile) container_layout.addSpacing(15) @@ -2790,6 +2988,10 @@ class Page_InitRecCompany(QWidget): QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) + def _enable_company_profile(self) -> None: + self._sync_GUI_to_state() + self._sync_state_to_GUI("comp_profile") + def _request_company_profile(self) -> None: assert self.STATE.un_id is not None, ( "requested company profile for a company which is not known (no ID)" @@ -2804,14 +3006,25 @@ class Page_InitRecCompany(QWidget): def reset(self) -> None: self.auto_form.reset_form() - def _sync_state_to_GUI(self) -> None: - auto_form_state = AutoForm_State( - session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, - rec_id=self.STATE.un_id, - form_data=None, - ) - self.auto_form.load_state(auto_form_state) + def _sync_state_to_GUI( + self, + section: Literal["auto_form", "comp_profile"] | None, + ) -> None: + if section is None or section == "auto_form": + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + rec_id=self.STATE.un_id, + form_data=None, + locked=self.STATE.locked, + ) + self.auto_form.load_state(auto_form_state) + + if section is None or section == "comp_profile": + if self.STATE.un_id is None: + self.comp_profile_btn.setEnabled(False) + else: + self.comp_profile_btn.setEnabled(True) def _sync_GUI_to_state(self) -> None: auto_form_state = self.auto_form.get_state() @@ -2823,13 +3036,21 @@ class Page_InitRecCompany(QWidget): ) -> None: set_page_state(self.STATE, new_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def get_state(self) -> Page_InitRecCompany_State: self._sync_GUI_to_state() return self.STATE + def lock(self) -> None: + self.auto_form.lock_form() + self.STATE.locked = True + + def unlock(self) -> None: + self.auto_form.unlock_form() + self.STATE.locked = False + class Page_InitRecPerson(QWidget): back_main_requested = Signal() # back to main page @@ -2901,7 +3122,11 @@ class Page_InitRecPerson(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_PERSONEN - auto_form_state = AutoForm_State(session=self.STATE.session, cfg=self.AUTO_FORM_CFG) + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + locked=False, + ) self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) @@ -2952,6 +3177,7 @@ class Page_InitRecPerson(QWidget): cfg=self.AUTO_FORM_CFG, rec_id=self.STATE.pers_id, form_data=None, + locked=self.STATE.locked, ) self.auto_form.load_state(auto_form_state) @@ -2971,6 +3197,14 @@ class Page_InitRecPerson(QWidget): return self.STATE + def lock(self) -> None: + self.auto_form.lock_form() + self.STATE.locked = True + + def unlock(self) -> None: + self.auto_form.unlock_form() + self.STATE.locked = False + class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page @@ -3246,6 +3480,7 @@ class Page_CompanyProfile(QWidget): req_state = Page_InitRecCompany_State( session=self.STATE.session, un_id=self.STATE.rec_id, + locked=True, ) logger_gui.debug("[Page Company Profile] State to call: %s", req_state) self.back_requested.emit(req_state) @@ -3262,6 +3497,7 @@ class Page_CompanyProfile(QWidget): vorgang_id=None, un_id=self.STATE.rec_id, beratungs_typ=cons_type, + locked=False, ) self.consulting_requested.emit(new_state) @@ -3277,6 +3513,7 @@ class Page_CompanyProfile(QWidget): vorgang_id=data.cons_id, un_id=self.STATE.rec_id, beratungs_typ=data.cons_type, + locked=True, ) self.consulting_requested.emit(new_state) @@ -3574,6 +3811,7 @@ class Page_Consulting(QWidget): session=session, vorgang_id=None, beratungs_typ=ConsultingType.PAUSCHAL, + locked=False, ) # Hauptlayout der Seite @@ -3658,6 +3896,12 @@ class Page_Consulting(QWidget): ) db_index_set_button.clicked.connect(self._debug_load_database) + lock_button = QPushButton("Seite sperren") + lock_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + lock_button.clicked.connect(self._debug_load_database) + db_index_layout.addWidget(self.db_index_field) db_index_layout.addWidget(db_index_set_button) container_layout.addLayout(db_index_layout) @@ -3678,13 +3922,18 @@ class Page_Consulting(QWidget): container_layout.addWidget(self.info_banners_container) # --- BUTTONS --- - self.edit_buttons = EditButtons(add_reset_btn=False) + self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=False) + modify_btn = self.edit_buttons.modify_button + assert modify_btn + self.modify_btn = modify_btn # reset_btn = self.edit_buttons.reset_btn # assert reset_btn self.save_btn = self.edit_buttons.save_btn # self.reset_btn = reset_btn self.delete_btn = self.edit_buttons.delete_btn + self.modify_btn.modify_activated.connect(self.unlock) + self.modify_btn.modify_deactivated.connect(self.lock) self.save_btn.clicked.connect(self.save_data) # self.reset_btn.clicked.connect(self.reset_form) self.delete_btn.clicked.connect(self._delete_data) @@ -3707,7 +3956,10 @@ class Page_Consulting(QWidget): type_layout.addWidget(self.type_individual_btn) # container_layout.addSpacing(50) - table_state = Page_Consulting_Table_State(session=self.STATE.session) + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + locked=self.STATE.locked, + ) self.table_sessions = Page_Consulting_Table(state=table_state) self.STATE.child_modules.append(self.table_sessions) container_layout.addWidget(self.table_sessions) @@ -3743,7 +3995,10 @@ class Page_Consulting(QWidget): # font-weight: bold; # } # """) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) + + def _lock_page(self) -> None: + pass def _delete_data(self) -> None: logger_page_consulting.debug("[Consulting Page] Call data delete...") @@ -3782,7 +4037,7 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) self.STATE.beratungs_typ = cons_type - self._sync_state_to_GUI() + self._sync_state_to_GUI("cons_type") def _debug_get_state(self) -> None: logger_page_consulting.debug("[Consulting Page] Call get state on table...") @@ -3839,57 +4094,72 @@ class Page_Consulting(QWidget): self.info_banners.append(banner) self.info_banners_layout.addWidget(banner) - def _sync_state_to_GUI(self) -> None: - match self.STATE.beratungs_typ: - case ConsultingType.PAUSCHAL: - self.type_pauschal_btn.setChecked(True) - case ConsultingType.INDIVIDUAL: - self.type_individual_btn.setChecked(True) + def _sync_state_to_GUI( + self, + section: Literal["cons_type", "banners", "table", "locking"] | None, + ) -> None: + if section is None or section == "cons_type" or section == "locking": + match self.STATE.beratungs_typ: + case ConsultingType.PAUSCHAL: + self.type_pauschal_btn.setChecked(True) + case ConsultingType.INDIVIDUAL: + self.type_individual_btn.setChecked(True) - # deactivate if at least one ID is set (type is pre-defined by how - # the site is called) - if self.STATE.un_id or self.STATE.pers_id: - self.type_pauschal_btn.setEnabled(False) - self.type_individual_btn.setEnabled(False) - else: - self.type_pauschal_btn.setEnabled(True) - self.type_individual_btn.setEnabled(True) + # deactivate if at least one ID is set (type is pre-defined by how + # the site is called) + if self.STATE.un_id or self.STATE.pers_id or self.STATE.locked: + self.type_pauschal_btn.setEnabled(False) + self.type_individual_btn.setEnabled(False) + else: + self.type_pauschal_btn.setEnabled(True) + self.type_individual_btn.setEnabled(True) # info banners needed - self._clear_info_banners() - if self.STATE.vorgang_id is None: - self._add_linking_banner( - text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", - button_text="", - ) - else: - if self.STATE.un_id is None and self.STATE.pers_id is None: + if section is None or section == "banners": + self._clear_info_banners() + if self.STATE.vorgang_id is None: self._add_linking_banner( - text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", - button_text="Jetzt verknüpfen", - button_func=self._open_linking_dialogue, - ) - elif ( - self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL - and self.STATE.pers_id is None - ): - self._add_linking_banner( - text=( - "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " - "einer Person fehlt." - ), - button_text="Jetzt verknüpfen", - button_func=self._open_linking_dialogue, + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", ) + else: + if self.STATE.un_id is None and self.STATE.pers_id is None: + self._add_linking_banner( + text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) + elif ( + self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL + and self.STATE.pers_id is None + ): + self._add_linking_banner( + text=( + "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " + "einer Person fehlt." + ), + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) # child modules # call to child modules' syncing method not needed because these are initialised by # their own "load_state" method which again calls the modules sync method - table_state = Page_Consulting_Table_State( - session=self.STATE.session, - row_states=self.STATE.cons_sessions, - ) - self.table_sessions.load_state(table_state) + if section is None or section == "table": + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + row_states=self.STATE.cons_sessions, + locked=self.STATE.locked, + ) + self.table_sessions.load_state(table_state) + + if section is None or section == "locking": + if self.STATE.locked: + self.table_sessions.lock() + self.edit_buttons.set_modify_state(False) + else: + self.table_sessions.unlock() + self.edit_buttons.set_modify_state(True) def _sync_GUI_to_state(self) -> None: # call to child modules' syncing method not needed because their syncing method is @@ -3912,7 +4182,7 @@ class Page_Consulting(QWidget): logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) set_page_state(self.STATE, new_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def load_database( self, @@ -3960,6 +4230,7 @@ class Page_Consulting(QWidget): self.STATE.pers_id = data.pers_id self.STATE.titel = data.titel self.STATE.ist_geloescht = data.geloescht is not None + self.STATE.locked = True if update_only: relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht] @@ -3987,10 +4258,11 @@ class Page_Consulting(QWidget): thema_crm_matrix=pydantic_state.thema_crm_matrix, anmerkungen=pydantic_state.anmerkungen, rueckmeldung=pydantic_state.rueckmeldung, + locked=self.STATE.locked, ) self.STATE.cons_sessions.append(cons_session_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def get_state(self) -> Page_Consulting_State: self._sync_GUI_to_state() @@ -4121,6 +4393,14 @@ class Page_Consulting(QWidget): # always re-enable save, even if error occurred self._enable_save() + def lock(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") + class Page_Consulting_ConsultingSession(QWidget): """child of 'Page_Consulting_DynamicTable'""" @@ -4265,7 +4545,7 @@ class Page_Consulting_ConsultingSession(QWidget): layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def _delete_request(self) -> None: confirm = get_user_confirmation( @@ -4277,18 +4557,32 @@ class Page_Consulting_ConsultingSession(QWidget): self.STATE.ist_geloescht = True self.delete_request.emit(self) - def _sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI( + self, + section: Literal["values", "locking"] | None, + ) -> None: logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) - set_widget_value(self.user_field, self.STATE.nutzer_name) - if self.STATE.zeitstempel is not None: - set_widget_value(self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)) - set_widget_value(self.contact, self.STATE.ansprechpartner) - set_widget_value(self.contact_type, self.STATE.kommunikationsweg) - set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) - set_widget_value(self.plain_text, self.STATE.anmerkungen) - set_widget_value(self.response, self.STATE.rueckmeldung) + if section is None or section == "values": + set_widget_value(self.user_field, self.STATE.nutzer_name) + if self.STATE.zeitstempel is not None: + set_widget_value( + self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST) + ) + set_widget_value(self.contact, self.STATE.ansprechpartner) + set_widget_value(self.contact_type, self.STATE.kommunikationsweg) + set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) + set_widget_value(self.plain_text, self.STATE.anmerkungen) + set_widget_value(self.response, self.STATE.rueckmeldung) + + if section is None or section == "locking": + if self.STATE.locked: + lock_form(self.widget_registry) + self.btn_delete.setEnabled(False) + else: + unlock_form(self.widget_registry) + self.btn_delete.setEnabled(False) def _sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() @@ -4320,7 +4614,15 @@ class Page_Consulting_ConsultingSession(QWidget): new_state: Page_Consulting_ConsultingSession_State, ) -> None: set_page_state(self.STATE, new_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) + + def lock(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") class Page_Consulting_Table(QWidget): @@ -4481,11 +4783,11 @@ class Page_Consulting_Table(QWidget): beratung_id=None, nutzer_id=self.STATE.session.user_id, nutzer_name=self.STATE.session.user_name, + locked=False, ) row = Page_Consulting_ConsultingSession(state=row_state) row.delete_request.connect(self.remove_row) - # sys.exit(0) index = self.container_layout.count() - 1 self.container_layout.insertWidget(index, row) @@ -4507,15 +4809,29 @@ class Page_Consulting_Table(QWidget): while self.STATE.child_modules: self.remove_row(self.STATE.child_modules[0]) - def _sync_state_to_GUI(self) -> None: + def _sync_state_to_GUI( + self, + section: Literal["rows", "locking"] | None, + ) -> None: self.setUpdatesEnabled(False) - self.remove_all_rows() - # states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht] - # for row_state in states_to_show: - for row_state in self.STATE.row_states: - if not row_state.ist_geloescht: - self.add_row(row_state, True) + if section is None or section == "rows": + self.remove_all_rows() + # states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht] + # for row_state in states_to_show: + for row_state in self.STATE.row_states: + if not row_state.ist_geloescht: + self.add_row(row_state, True) + + if section is None or section == "locking": + if self.STATE.locked: + self.btn_add.setEnabled(False) + for m in self.STATE.child_modules: + m.lock() + else: + self.btn_add.setEnabled(True) + for m in self.STATE.child_modules: + m.unlock() self.setUpdatesEnabled(True) @@ -4559,8 +4875,7 @@ class Page_Consulting_Table(QWidget): "[Page -- Consulting] Table: Call loading from state, %s", new_state ) set_page_state(self.STATE, new_state) - - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def validate(self) -> list[str]: errors: list[str] = [] @@ -4572,6 +4887,14 @@ class Page_Consulting_Table(QWidget): def save_data(self) -> None: pass + def lock(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") + class InfoBanner(QFrame): btn_clicked = Signal() @@ -4672,7 +4995,9 @@ class MainWindow(QMainWindow): self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.new_initrec_select) # SITE: 'Grunderfassung Unternehmen' - initrec_company_state = Page_InitRecCompany_State(session=self.STATE.session) + initrec_company_state = Page_InitRecCompany_State( + session=self.STATE.session, locked=False + ) self.initrec_company = Page_InitRecCompany(initrec_company_state) self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) @@ -4680,7 +5005,9 @@ class MainWindow(QMainWindow): self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' - initrec_person_state = Page_InitRecPerson_State(session=self.STATE.session) + initrec_person_state = Page_InitRecPerson_State( + session=self.STATE.session, locked=False + ) self.initrec_person = Page_InitRecPerson(initrec_person_state) self.initrec_person.back_main_requested.connect(self.show_main_page) self.initrec_person.back_requested.connect(self.show_new_entry_select) @@ -4826,13 +5153,17 @@ class MainWindow(QMainWindow): target: QWidget if data.type is types.InitRecType.COMPANY: new_state = Page_InitRecCompany_State( - session=self.STATE.session, un_id=data.rec_id + session=self.STATE.session, + un_id=data.rec_id, + locked=True, ) self.initrec_company.load_state(new_state) target = self.initrec_company elif data.type is types.InitRecType.PERSON: new_state = Page_InitRecPerson_State( - session=self.STATE.session, pers_id=data.rec_id + session=self.STATE.session, + pers_id=data.rec_id, + locked=True, ) self.initrec_person.load_state(new_state) target = self.initrec_person @@ -4926,13 +5257,6 @@ def global_exception_handler(exc_type, exc_value, exc_traceback): "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", error_msg, ) - # msg_box.setIcon(QMessageBox.Icon.Critical) - # msg_box.setWindowTitle("Kritischer Fehler") - # msg_box.setText( - # "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert." - # ) - # # details for user or screenshots - # msg_box.setDetailedText(error_msg) msg_box.exec() sys.exit(1) @@ -5012,15 +5336,93 @@ def GUI_pydantic_validation_error_handling( msg_box.exec() +class _ModifyButton(QPushButton): + modify_activated = Signal() + modify_deactivated = Signal() + + def __init__( + self, + external_state_management: bool, + txt_activate: str = "Bearbeitung aktivieren (Strg + B)", + txt_deactivate: str = "Bearbeitung deaktivieren (Strg + B)", + shortcut: str = "Ctrl+B", + parent: QWidget | None = None, + ) -> None: + super().__init__(txt_activate, parent=parent) + self.txt_activate = txt_activate + self.txt_deactivate = txt_deactivate + self.custom_shortcut: ( + QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int + ) = shortcut + self.is_modify_state = False + self.external_state_management = external_state_management + + self.setShortcut(self.custom_shortcut) + + self.clicked.connect(self._clicked) + + def set_modify_state( + self, + enable_modification: bool, + ) -> None: + if enable_modification: + self.setText(self.txt_deactivate) + else: + self.setText(self.txt_activate) + + self.setShortcut(self.custom_shortcut) + self.is_modify_state = enable_modification + + def _clicked(self) -> None: + if self.is_modify_state: + self.setText(self.txt_activate) + self.modify_deactivated.emit() + else: + self.setText(self.txt_deactivate) + self.modify_activated.emit() + + self.setShortcut(self.custom_shortcut) + if not self.external_state_management: + self.is_modify_state = not self.is_modify_state + + @override + def setEnabled( + self, + enabled: bool, + ) -> None: + super().setEnabled(enabled) + if enabled: + self.setShortcut(self.custom_shortcut) + + @override + def setShortcut( + self, + key: QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int, + ) -> None: + self.custom_shortcut = key + super().setShortcut(key) + + class EditButtons(QWidget): def __init__( self, + add_modify_btn: bool, add_reset_btn: bool, ) -> None: super().__init__() layout_btn = QHBoxLayout(self) layout_btn.setContentsMargins(0, 0, 0, 0) + # modify + self.modify_button: _ModifyButton | None = None + if add_modify_btn: + self.modify_button = _ModifyButton(external_state_management=True) + self.modify_button.setFixedHeight(50) + self.modify_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + layout_btn.addWidget(self.modify_button) + # save self.save_btn_txt_enabled = "Speichern (Strg + S)" self.save_btn_txt_disabled = "Wird gespeichert..." @@ -5046,6 +5448,16 @@ class EditButtons(QWidget): self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout_btn.addWidget(self.delete_btn) + def set_modify_state( + self, + enable_modification: bool, + ) -> None: + print(f">>>>>>>>> SET modify GROUP: {enable_modification=}") + if self.modify_button is not None: + self.modify_button.set_modify_state(enable_modification) + self.save_btn.setEnabled(enable_modification) + self.delete_btn.setEnabled(enable_modification) + def qt_message_handler(mode, context, message): """forwards internal Qt C++ warnings to Python loggers""" @@ -5067,6 +5479,9 @@ if TYPE_CHECKING: _m3: type[Module] = Page_Consulting_ConsultingSession _w1: type[WrapperModule] = Page_InitRecCompany _w2: type[WrapperModule] = Page_InitRecPerson + _f1: type[CustomForm] = DynamicListWidget + _f2: type[CustomForm] = DynamicDropdownWidgetNumeric + _f3: type[CustomForm] = DynamicDropdownWidgetOption def main() -> None: -- 2.34.1 From 393e3ba39ba205bdd10017511ab64db36e045b78 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 15:28:54 +0200 Subject: [PATCH 35/74] added info banners to initial recording --- src/wce_crm/gui.py | 128 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 109 insertions(+), 19 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 026ae14..251d9f7 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -2947,6 +2947,14 @@ class Page_InitRecCompany(QWidget): header_layout.addWidget(self.comp_profile_btn) vert_layout.addWidget(header_container) + # --- BANNER --- + self.info_banners_container = QWidget() + self.info_banners: list[InfoBanner] = [] + self.info_banners_layout = QVBoxLayout(self.info_banners_container) + self.info_banners_layout.setContentsMargins(0, 0, 0, 0) + self.info_banners_layout.setSpacing(6) + header_layout.addWidget(self.info_banners_container) + # --- MAIN CONTENT --- container = QWidget() # SCROLL AREA @@ -2975,7 +2983,7 @@ class Page_InitRecCompany(QWidget): self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_main_page.emit()) - self.auto_form.update_triggered.connect(self._enable_company_profile) + self.auto_form.update_triggered.connect(self._auto_form_updated) container_layout.addSpacing(15) @@ -2988,9 +2996,35 @@ class Page_InitRecCompany(QWidget): QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) - def _enable_company_profile(self) -> None: + def _clear_info_banners(self) -> None: + while self.info_banners: + banner = self.info_banners[0] + self.info_banners_layout.removeWidget(banner) + self.info_banners.remove(banner) + banner.deleteLater() + + def _add_info_banner( + self, + text: str, + button_text: str, + button_func: Callable[[], None] | None = None, + ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + + banner = InfoBanner( + text=text, + button_text=button_text, + ) + if button_func is not None: + banner.btn_clicked.connect(button_func) + self.info_banners.append(banner) + self.info_banners_layout.addWidget(banner) + + def _auto_form_updated(self) -> None: self._sync_GUI_to_state() self._sync_state_to_GUI("comp_profile") + self._sync_state_to_GUI("banners") def _request_company_profile(self) -> None: assert self.STATE.un_id is not None, ( @@ -3008,7 +3042,7 @@ class Page_InitRecCompany(QWidget): def _sync_state_to_GUI( self, - section: Literal["auto_form", "comp_profile"] | None, + section: Literal["auto_form", "comp_profile", "banners"] | None, ) -> None: if section is None or section == "auto_form": auto_form_state = AutoForm_State( @@ -3026,6 +3060,14 @@ class Page_InitRecCompany(QWidget): else: self.comp_profile_btn.setEnabled(True) + if section is None or section == "banners": + self._clear_info_banners() + if self.STATE.un_id is None: + self._add_info_banner( + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", + ) + def _sync_GUI_to_state(self) -> None: auto_form_state = self.auto_form.get_state() self.STATE.un_id = auto_form_state.rec_id @@ -3102,6 +3144,14 @@ class Page_InitRecPerson(QWidget): header_layout.addWidget(title) vert_layout.addWidget(header_container) + # --- BANNER --- + self.info_banners_container = QWidget() + self.info_banners: list[InfoBanner] = [] + self.info_banners_layout = QVBoxLayout(self.info_banners_container) + self.info_banners_layout.setContentsMargins(0, 0, 0, 0) + self.info_banners_layout.setSpacing(6) + header_layout.addWidget(self.info_banners_container) + # --- MAIN CONTENT --- container = QWidget() # SCROLL AREA @@ -3130,6 +3180,7 @@ class Page_InitRecPerson(QWidget): self.auto_form = AutoForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) + self.auto_form.update_triggered.connect(self._auto_form_updated) container_layout.addSpacing(15) @@ -3168,18 +3219,59 @@ class Page_InitRecPerson(QWidget): self.selection_county.style().polish(self.selection_county) self.selection_county.update() + def _clear_info_banners(self) -> None: + while self.info_banners: + banner = self.info_banners[0] + self.info_banners_layout.removeWidget(banner) + self.info_banners.remove(banner) + banner.deleteLater() + + def _add_info_banner( + self, + text: str, + button_text: str, + button_func: Callable[[], None] | None = None, + ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + + banner = InfoBanner( + text=text, + button_text=button_text, + ) + if button_func is not None: + banner.btn_clicked.connect(button_func) + self.info_banners.append(banner) + self.info_banners_layout.addWidget(banner) + + def _auto_form_updated(self) -> None: + self._sync_GUI_to_state() + self._sync_state_to_GUI("banners") + def reset_form(self) -> None: self.auto_form.reset_form() - def _sync_state_to_GUI(self) -> None: - auto_form_state = AutoForm_State( - session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, - rec_id=self.STATE.pers_id, - form_data=None, - locked=self.STATE.locked, - ) - self.auto_form.load_state(auto_form_state) + def _sync_state_to_GUI( + self, + section: Literal["auto_form", "banners"] | None, + ) -> None: + if section is None or section == "auto_form": + auto_form_state = AutoForm_State( + session=self.STATE.session, + cfg=self.AUTO_FORM_CFG, + rec_id=self.STATE.pers_id, + form_data=None, + locked=self.STATE.locked, + ) + self.auto_form.load_state(auto_form_state) + + if section is None or section == "banners": + self._clear_info_banners() + if self.STATE.pers_id is None: + self._add_info_banner( + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", + ) def _sync_GUI_to_state(self) -> None: auto_form_state = self.auto_form.get_state() @@ -3190,7 +3282,7 @@ class Page_InitRecPerson(QWidget): new_state: Page_InitRecPerson_State, ) -> None: set_page_state(self.STATE, new_state) - self._sync_state_to_GUI() + self._sync_state_to_GUI(None) def get_state(self) -> Page_InitRecPerson_State: self._sync_GUI_to_state() @@ -4076,7 +4168,7 @@ class Page_Consulting(QWidget): self.info_banners.remove(banner) banner.deleteLater() - def _add_linking_banner( + def _add_info_banner( self, text: str, button_text: str, @@ -4118,13 +4210,13 @@ class Page_Consulting(QWidget): if section is None or section == "banners": self._clear_info_banners() if self.STATE.vorgang_id is None: - self._add_linking_banner( + self._add_info_banner( text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", button_text="", ) else: if self.STATE.un_id is None and self.STATE.pers_id is None: - self._add_linking_banner( + self._add_info_banner( text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", button_text="Jetzt verknüpfen", button_func=self._open_linking_dialogue, @@ -4133,7 +4225,7 @@ class Page_Consulting(QWidget): self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL and self.STATE.pers_id is None ): - self._add_linking_banner( + self._add_info_banner( text=( "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " "einer Person fehlt." @@ -4235,7 +4327,6 @@ class Page_Consulting(QWidget): if update_only: relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht] rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None] - print(f">>>>>>> {pformat(relevant_gui_states)}") assert len(relevant_gui_states) == len(rel_cons_session_states) for gui_state, pydantic_state in zip( relevant_gui_states, rel_cons_session_states @@ -5452,7 +5543,6 @@ class EditButtons(QWidget): self, enable_modification: bool, ) -> None: - print(f">>>>>>>>> SET modify GROUP: {enable_modification=}") if self.modify_button is not None: self.modify_button.set_modify_state(enable_modification) self.save_btn.setEnabled(enable_modification) -- 2.34.1 From e6dc2644acd99ab0d49f4ebf6727341cb8ea1b89 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 15:35:46 +0200 Subject: [PATCH 36/74] add message boxes for saving procedure in initial recording --- src/wce_crm/gui.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 251d9f7..a2a3ac5 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1679,19 +1679,38 @@ class AutoForm(QWidget): "[Auto-Form] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), ) + try: + if self.STATE.rec_id is None: + logger_auto_form.debug("[Auto-Form] Insert triggered...") + init_rec_id = self.cfg.data_insert(db_data) + assert isinstance(init_rec_id, int) + self.STATE.rec_id = init_rec_id + else: + logger_auto_form.debug("[Auto-Form] Update triggered...") + self.cfg.data_update(self.STATE.rec_id, db_data) - if self.STATE.rec_id is None: - logger_auto_form.debug("[Auto-Form] Insert triggered...") - init_rec_id = self.cfg.data_insert(db_data) - assert isinstance(init_rec_id, int) - self.STATE.rec_id = init_rec_id + self.update_triggered.emit() + self._activate_delete() + except Exception as err: + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Speichern fehlgeschlagen", + ( + "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=str(err), + ) + msg_box.exec() else: - logger_auto_form.debug("[Auto-Form] Update triggered...") - self.cfg.data_update(self.STATE.rec_id, db_data) + logger_auto_form.info("[Auto-Form] Data saved successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Speichern erfolgreich", + "Die Daten wurden erfolgreich gespeichert", + ) + msg_box.exec() - logger_auto_form.info("[Auto-Form] Data saved successfully") - self.update_triggered.emit() - self._activate_delete() finally: # always re-enable save, even if error occurred self._enable_save() -- 2.34.1 From af6202555838c7d3cc7f3f2b3dbea85364b48e9d Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 15:52:32 +0200 Subject: [PATCH 37/74] enable loading of consultations on company profile --- src/wce_crm/gui.py | 606 +++++++++++++++++++++++---------------------- 1 file changed, 305 insertions(+), 301 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index a2a3ac5..37bb5d8 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3669,6 +3669,9 @@ class Page_CompanyProfile(QWidget): self.layout_cons_indiv.insertWidget(target_idx, click_cell) self.cons_indiv.append(click_cell) + def update_current_page(self) -> None: + self._update_consultations() + def _disable_add_btns(self) -> None: self.add_btn_pauschal.setEnabled(False) self.add_btn_individual.setEnabled(False) @@ -3700,6 +3703,7 @@ class Page_CompanyProfile(QWidget): self.table_master_data.load_data(data) self.set_company_name(data["Name:"]) + self.update_current_page() def _sync_GUI_to_state(self) -> None: ... @@ -4496,8 +4500,7 @@ class Page_Consulting(QWidget): "Die Daten wurden erfolgreich gespeichert", ) msg_box.exec() - # TODO check triggers for rest of application - # self.update_triggered.emit() + self.update_triggered.emit() # self._activate_delete() finally: # always re-enable save, even if error occurred @@ -5073,305 +5076,6 @@ def clear_layout( clear_layout(child.layout()) -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - - # STATE MANAGEMENT - session = Session(user_id=42, user_name="TEST-USER") - self.STATE = Page_MainPage_State(session=session) - self._history_stack: list[int] = [] - - self.setWindowTitle("Master") - self.resize(1800, 1000) - - # MENU - self.create_menu() - - # STACK: stack to change between 'sites' - self.stack = QStackedWidget() - self.setCentralWidget(self.stack) - - # MAIN PAGE: table view - self.main_page = self.setup_main_page() - self.stack.addWidget(self.main_page) - - # SITE: add new entries for 'Grunderfassung' - new_initrec_select_state = Page_NewInitRec_State(session=self.STATE.session) - self.new_initrec_select = Page_NewInitRec(new_initrec_select_state) - self.new_initrec_select.back_requested.connect(self.show_main_page) - self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) - self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) - self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) - self.stack.addWidget(self.new_initrec_select) - # SITE: 'Grunderfassung Unternehmen' - initrec_company_state = Page_InitRecCompany_State( - session=self.STATE.session, locked=False - ) - self.initrec_company = Page_InitRecCompany(initrec_company_state) - self.initrec_company.back_main_requested.connect(self.show_main_page) - self.initrec_company.back_requested.connect(self.show_new_entry_select) - self.initrec_company.update_main_page.connect(self.update_grid) - self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) - self.stack.addWidget(self.initrec_company) - # SITE: 'Grunderfassung Person' - initrec_person_state = Page_InitRecPerson_State( - session=self.STATE.session, locked=False - ) - self.initrec_person = Page_InitRecPerson(initrec_person_state) - self.initrec_person.back_main_requested.connect(self.show_main_page) - self.initrec_person.back_requested.connect(self.show_new_entry_select) - self.initrec_person.update_triggered.connect(self.update_grid) - self.stack.addWidget(self.initrec_person) - # SITE: 'Unternehmensprofil' - comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) - self.company_profile = Page_CompanyProfile(comp_profile_state) - self.company_profile.back_main_requested.connect(self.show_main_page) - self.company_profile.back_requested.connect(self.show_page_initrec_company) - self.company_profile.consulting_requested.connect(self.show_page_consulting) - self.stack.addWidget(self.company_profile) - # SITE: 'Beratungsgespräch' - self.consulting_page = Page_Consulting(self.STATE.session) - self.consulting_page.back_main_requested.connect(self.show_main_page) - # self.consulting_page.back_requested.connect( - # self.show_page_company_profile_without_load - # ) - self.consulting_page.back_requested.connect(self.simple_go_back) - self.stack.addWidget(self.consulting_page) - - def setup_main_page(self): - # QMainWindow defines frame --> container widget needed for the middle - main_widget = QWidget() - outer_layout = QHBoxLayout(main_widget) - vert_layout = QVBoxLayout() - - # add buttons - new_btn = QPushButton("Neu →") - new_btn.setFixedWidth(100) - new_btn.setFixedHeight(40) - new_btn.clicked.connect(self.show_new_entry_select) - vert_layout.addWidget(new_btn) - - if DEBUG: - update_btn = QPushButton("UPDATE") - update_btn.setFixedWidth(100) - update_btn.setFixedHeight(40) - update_btn.clicked.connect(self.update_grid) - clear_btn = QPushButton("CLEAR") - clear_btn.setFixedWidth(100) - clear_btn.setFixedHeight(40) - clear_btn.clicked.connect(self._clear_layout) - vert_layout.addWidget(update_btn) - vert_layout.addWidget(clear_btn) - separator = QFrame() - separator.setFrameShape(QFrame.Shape.HLine) - separator.setFrameShadow(QFrame.Shadow.Sunken) - vert_layout.addWidget(separator) - - # container for table or grid - container = QWidget() - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setMinimumWidth(700) - scroll_area.setMaximumWidth(1500) - scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - # delete frame of ScrollArea to get a flat look - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - scroll_area.setWidget(container) - - vert_layout.addSpacing(20) - vert_layout.addWidget(scroll_area) - # springs left and right of container to center the it - outer_layout.addStretch(1) - outer_layout.addLayout(vert_layout, stretch=100) - outer_layout.addStretch(1) - outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - # Wir geben dem Container ein vertikales Layout - container_layout = QVBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - # grid for table without direct container - self.header_grid = QGridLayout() - self.header_grid.setColumnStretch(0, 1) - self.header_grid.setColumnStretch(1, 1) - self.header_grid.setColumnStretch(2, 1) - self.header_grid.setColumnStretch(3, 1) - self.header_grid.setColumnMinimumWidth(4, 100) - - self.grid = QGridLayout() - self.grid.setSpacing(10) - self.grid.setColumnStretch(0, 1) - self.grid.setColumnStretch(1, 1) - self.grid.setColumnStretch(2, 1) - self.grid.setColumnStretch(3, 1) - self.grid.setColumnMinimumWidth(4, 100) - - # add grid to vertical layout - container_layout.addLayout(self.header_grid) - container_layout.addLayout(self.grid) - # spring below the layout to push it to top - container_layout.addStretch() - - self.current_row = 0 - headers = [ - "UN/ NWP/ Kontaktperson", - "Individualberatung", - "Pauschalberatung", - "Verlaufsprotokoll", - "Datum", - ] - for col_idx, title in enumerate(headers): - self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) - - self.update_grid() - - return main_widget - - def _clear_layout(self) -> None: - clear_layout(self.grid) - - def update_grid(self) -> None: - clear_layout(self.grid) - - data = backend.main_page_get_entries() - - for entry in data: - self.add_row_to_grid(entry) - - def add_row_to_grid( - self, - entry: types.MainPageEntry, - ): - row = self.current_row - - # NAME - cell_name = MainPage_ClickableCell(entry.display_name, entry) - cell_name.clicked.connect(self.goto_initial_recording) - self.grid.addWidget(cell_name, row, 0) - # DATE - cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) - cell_date = MainPage_ClickableCell(cell_date_name, entry) - cell_date.clicked.connect(self.goto_initial_recording) - self.grid.addWidget(cell_date, row, 4) - - self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen - - def goto_initial_recording( - self, - data: types.MainPageEntry, - ): - target: QWidget - if data.type is types.InitRecType.COMPANY: - new_state = Page_InitRecCompany_State( - session=self.STATE.session, - un_id=data.rec_id, - locked=True, - ) - self.initrec_company.load_state(new_state) - target = self.initrec_company - elif data.type is types.InitRecType.PERSON: - new_state = Page_InitRecPerson_State( - session=self.STATE.session, - pers_id=data.rec_id, - locked=True, - ) - self.initrec_person.load_state(new_state) - target = self.initrec_person - else: - raise TypeError(f"Unknown init rec type: {data.type}") - - self._navigate_to_target(target) - - def _navigate_to_target( - self, - widget: QWidget, - ) -> None: - curr_idx = self.stack.currentIndex() - - if curr_idx != -1: - self._history_stack.append(curr_idx) - - self.stack.setCurrentWidget(widget) - - def simple_go_back(self) -> None: - if not self._history_stack: - logger_gui.info("[main program] Called >back< on empty history") - - prev_idx = self._history_stack.pop() - self.stack.setCurrentIndex(prev_idx) - - def show_main_page(self): - self._history_stack.clear() - self._navigate_to_target(self.main_page) - - def show_new_entry_select(self): - self._navigate_to_target(self.new_initrec_select) - - def show_page_initrec_company( - self, - new_state: Page_InitRecCompany_State, - ): - self.initrec_company.load_state(new_state) - self._navigate_to_target(self.initrec_company) - - def show_page_initrec_person( - self, - new_state: Page_InitRecPerson_State, - ): - self.initrec_person.load_state(new_state) - self._navigate_to_target(self.initrec_person) - - def show_page_company_profile( - self, - new_state: Page_CompanyProfile_State, - ) -> None: - self.company_profile.load_state(new_state) - self._navigate_to_target(self.company_profile) - - def show_page_consulting( - self, - new_state: Page_Consulting_State, - ) -> None: - self.consulting_page.load_state(new_state) - self._navigate_to_target(self.consulting_page) - - # --- MENÜ LOGIK --- - def create_menu(self): - menu_bar = self.menuBar() - # file menu - file_menu = menu_bar.addMenu("Datei") - exit_action = QAction("Beenden", self) - exit_action.setShortcut("Ctrl+Q") - exit_action.triggered.connect(self.close) - file_menu.addAction(exit_action) - # help menu - help_menu = menu_bar.addMenu("Hilfe") - about_action = QAction("Über", self) - help_menu.addAction(about_action) - - -# ** global exception handling -def global_exception_handler(exc_type, exc_value, exc_traceback): - """catches all unhandled errors""" - - # format error - error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) - logger_gui.critical(f"UNEXPECTED ERROR:\n{error_msg}") - - # message to user - # check if QApplication exists otherwise crashing of crash handler possible) - if QApplication.instance(): - msg_box = get_message_box( - QMessageBox.Icon.Critical, - "Kritischer Fehler", - "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", - error_msg, - ) - msg_box.exec() - - sys.exit(1) - - def get_message_box( msg_type: QMessageBox.Icon, title: str, @@ -5568,6 +5272,306 @@ class EditButtons(QWidget): self.delete_btn.setEnabled(enable_modification) +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + # STATE MANAGEMENT + session = Session(user_id=42, user_name="TEST-USER") + self.STATE = Page_MainPage_State(session=session) + self._history_stack: list[int] = [] + + self.setWindowTitle("Master") + self.resize(1800, 1000) + + # MENU + self.create_menu() + + # STACK: stack to change between 'sites' + self.stack = QStackedWidget() + self.setCentralWidget(self.stack) + + # MAIN PAGE: table view + self.main_page = self.setup_main_page() + self.stack.addWidget(self.main_page) + + # SITE: add new entries for 'Grunderfassung' + new_initrec_select_state = Page_NewInitRec_State(session=self.STATE.session) + self.new_initrec_select = Page_NewInitRec(new_initrec_select_state) + self.new_initrec_select.back_requested.connect(self.show_main_page) + self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) + self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) + self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) + self.stack.addWidget(self.new_initrec_select) + # SITE: 'Grunderfassung Unternehmen' + initrec_company_state = Page_InitRecCompany_State( + session=self.STATE.session, locked=False + ) + self.initrec_company = Page_InitRecCompany(initrec_company_state) + self.initrec_company.back_main_requested.connect(self.show_main_page) + self.initrec_company.back_requested.connect(self.show_new_entry_select) + self.initrec_company.update_main_page.connect(self.update_grid) + self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) + self.stack.addWidget(self.initrec_company) + # SITE: 'Grunderfassung Person' + initrec_person_state = Page_InitRecPerson_State( + session=self.STATE.session, locked=False + ) + self.initrec_person = Page_InitRecPerson(initrec_person_state) + self.initrec_person.back_main_requested.connect(self.show_main_page) + self.initrec_person.back_requested.connect(self.show_new_entry_select) + self.initrec_person.update_triggered.connect(self.update_grid) + self.stack.addWidget(self.initrec_person) + # SITE: 'Unternehmensprofil' + comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) + self.company_profile = Page_CompanyProfile(comp_profile_state) + self.company_profile.back_main_requested.connect(self.show_main_page) + self.company_profile.back_requested.connect(self.show_page_initrec_company) + self.company_profile.consulting_requested.connect(self.show_page_consulting) + self.stack.addWidget(self.company_profile) + # SITE: 'Beratungsgespräch' + self.consulting_page = Page_Consulting(self.STATE.session) + self.consulting_page.back_main_requested.connect(self.show_main_page) + self.consulting_page.back_requested.connect(self.simple_go_back) + self.consulting_page.update_triggered.connect(self.update_page_company_profile) + self.stack.addWidget(self.consulting_page) + + def setup_main_page(self): + # QMainWindow defines frame --> container widget needed for the middle + main_widget = QWidget() + outer_layout = QHBoxLayout(main_widget) + vert_layout = QVBoxLayout() + + # add buttons + new_btn = QPushButton("Neu →") + new_btn.setFixedWidth(100) + new_btn.setFixedHeight(40) + new_btn.clicked.connect(self.show_new_entry_select) + vert_layout.addWidget(new_btn) + + if DEBUG: + update_btn = QPushButton("UPDATE") + update_btn.setFixedWidth(100) + update_btn.setFixedHeight(40) + update_btn.clicked.connect(self.update_grid) + clear_btn = QPushButton("CLEAR") + clear_btn.setFixedWidth(100) + clear_btn.setFixedHeight(40) + clear_btn.clicked.connect(self._clear_layout) + vert_layout.addWidget(update_btn) + vert_layout.addWidget(clear_btn) + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + vert_layout.addWidget(separator) + + # container for table or grid + container = QWidget() + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1500) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # delete frame of ScrollArea to get a flat look + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + vert_layout.addSpacing(20) + vert_layout.addWidget(scroll_area) + # springs left and right of container to center the it + outer_layout.addStretch(1) + outer_layout.addLayout(vert_layout, stretch=100) + outer_layout.addStretch(1) + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Wir geben dem Container ein vertikales Layout + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + # grid for table without direct container + self.header_grid = QGridLayout() + self.header_grid.setColumnStretch(0, 1) + self.header_grid.setColumnStretch(1, 1) + self.header_grid.setColumnStretch(2, 1) + self.header_grid.setColumnStretch(3, 1) + self.header_grid.setColumnMinimumWidth(4, 100) + + self.grid = QGridLayout() + self.grid.setSpacing(10) + self.grid.setColumnStretch(0, 1) + self.grid.setColumnStretch(1, 1) + self.grid.setColumnStretch(2, 1) + self.grid.setColumnStretch(3, 1) + self.grid.setColumnMinimumWidth(4, 100) + + # add grid to vertical layout + container_layout.addLayout(self.header_grid) + container_layout.addLayout(self.grid) + # spring below the layout to push it to top + container_layout.addStretch() + + self.current_row = 0 + headers = [ + "UN/ NWP/ Kontaktperson", + "Individualberatung", + "Pauschalberatung", + "Verlaufsprotokoll", + "Datum", + ] + for col_idx, title in enumerate(headers): + self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) + + self.update_grid() + + return main_widget + + def _clear_layout(self) -> None: + clear_layout(self.grid) + + def update_grid(self) -> None: + clear_layout(self.grid) + + data = backend.main_page_get_entries() + + for entry in data: + self.add_row_to_grid(entry) + + def add_row_to_grid( + self, + entry: types.MainPageEntry, + ): + row = self.current_row + + # NAME + cell_name = MainPage_ClickableCell(entry.display_name, entry) + cell_name.clicked.connect(self.goto_initial_recording) + self.grid.addWidget(cell_name, row, 0) + # DATE + cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) + cell_date = MainPage_ClickableCell(cell_date_name, entry) + cell_date.clicked.connect(self.goto_initial_recording) + self.grid.addWidget(cell_date, row, 4) + + self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen + + def goto_initial_recording( + self, + data: types.MainPageEntry, + ): + target: QWidget + if data.type is types.InitRecType.COMPANY: + new_state = Page_InitRecCompany_State( + session=self.STATE.session, + un_id=data.rec_id, + locked=True, + ) + self.initrec_company.load_state(new_state) + target = self.initrec_company + elif data.type is types.InitRecType.PERSON: + new_state = Page_InitRecPerson_State( + session=self.STATE.session, + pers_id=data.rec_id, + locked=True, + ) + self.initrec_person.load_state(new_state) + target = self.initrec_person + else: + raise TypeError(f"Unknown init rec type: {data.type}") + + self._navigate_to_target(target) + + def _navigate_to_target( + self, + widget: QWidget, + ) -> None: + curr_idx = self.stack.currentIndex() + + if curr_idx != -1: + self._history_stack.append(curr_idx) + + self.stack.setCurrentWidget(widget) + + def simple_go_back(self) -> None: + if not self._history_stack: + logger_gui.info("[main program] Called >back< on empty history") + + prev_idx = self._history_stack.pop() + self.stack.setCurrentIndex(prev_idx) + + def show_main_page(self): + self._history_stack.clear() + self._navigate_to_target(self.main_page) + + def show_new_entry_select(self): + self._navigate_to_target(self.new_initrec_select) + + def show_page_initrec_company( + self, + new_state: Page_InitRecCompany_State, + ): + self.initrec_company.load_state(new_state) + self._navigate_to_target(self.initrec_company) + + def show_page_initrec_person( + self, + new_state: Page_InitRecPerson_State, + ): + self.initrec_person.load_state(new_state) + self._navigate_to_target(self.initrec_person) + + def show_page_company_profile( + self, + new_state: Page_CompanyProfile_State, + ) -> None: + self.company_profile.load_state(new_state) + self._navigate_to_target(self.company_profile) + + def update_page_company_profile(self) -> None: + self.company_profile.update_current_page() + + def show_page_consulting( + self, + new_state: Page_Consulting_State, + ) -> None: + self.consulting_page.load_state(new_state) + self._navigate_to_target(self.consulting_page) + + # --- MENÜ LOGIK --- + def create_menu(self): + menu_bar = self.menuBar() + # file menu + file_menu = menu_bar.addMenu("Datei") + exit_action = QAction("Beenden", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + # help menu + help_menu = menu_bar.addMenu("Hilfe") + about_action = QAction("Über", self) + help_menu.addAction(about_action) + + +# ** global exception handling +def global_exception_handler(exc_type, exc_value, exc_traceback): + """catches all unhandled errors""" + + # format error + error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + logger_gui.critical(f"UNEXPECTED ERROR:\n{error_msg}") + + # message to user + # check if QApplication exists otherwise crashing of crash handler possible) + if QApplication.instance(): + msg_box = get_message_box( + QMessageBox.Icon.Critical, + "Kritischer Fehler", + "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", + error_msg, + ) + msg_box.exec() + + sys.exit(1) + + def qt_message_handler(mode, context, message): """forwards internal Qt C++ warnings to Python loggers""" if mode == QtMsgType.QtInfoMsg: -- 2.34.1 From f4a4d2d98e48684a9054e63113a745650975d0b0 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 17:08:06 +0200 Subject: [PATCH 38/74] updated schema for deletion by timestamp (equivalent to other parts of the app) --- ...ab_timestamps_for_deletions_of_initial_.py | 42 +++++++++++++++++++ data_model/project_db_schema.aml | 2 + src/wce_crm/db.py | 10 +++++ 3 files changed, 54 insertions(+) create mode 100644 alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py diff --git a/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py b/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py new file mode 100644 index 0000000..e367e15 --- /dev/null +++ b/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py @@ -0,0 +1,42 @@ +"""timestamps for deletions of initial recording + +Revision ID: 85577ce9f2ab +Revises: 830416cab29c +Create Date: 2026-07-15 17:06:55.947134 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "85577ce9f2ab" +down_revision: Union[str, Sequence[str], None] = "830416cab29c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "grunderfassung_personen", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + op.add_column( + "grunderfassung_unternehmen", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("grunderfassung_unternehmen", "geloescht") + op.drop_column("grunderfassung_personen", "geloescht") + # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 159ebf4..476670f 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -9,6 +9,7 @@ Ext_CRM.Ansprechpartner Grunderfassung_Unternehmen un_id int pk {autoIncrement} + geloescht "timestamp with time zone" nullable Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} Metadaten_nutzer varchar(20) nullable @@ -21,6 +22,7 @@ Grunderfassung_Unternehmen Grunderfassung_Personen pers_id int pk {autoIncrement} + geloescht "timestamp with time zone" nullable Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} Metadaten_wiedereintrittsdatum timestamp nullable diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 60df268..aaecaf0 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -256,6 +256,11 @@ grunderfassung_unternehmen: sql.Table = Table( primary_key=True, autoincrement=True, ), + Column( + "geloescht", + UTCDateTime, + nullable=True, + ), Column( "Metadaten_erstellung", UTCDateTime, @@ -286,6 +291,11 @@ grunderfassung_personen: sql.Table = Table( primary_key=True, autoincrement=True, ), + Column( + "geloescht", + UTCDateTime, + nullable=True, + ), Column( "Metadaten_erstellung", UTCDateTime, -- 2.34.1 From a40213a1ed4d1f4cbafafd1ef0f9eb947635c0c4 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 15 Jul 2026 17:28:24 +0200 Subject: [PATCH 39/74] prepare new loading scheme for auto form data (not working yet) --- src/wce_crm/backend/backend.py | 238 +++++++++++++++++++++++++++++++++ src/wce_crm/data_models.py | 30 +++++ src/wce_crm/gui.py | 67 +++++++--- 3 files changed, 316 insertions(+), 19 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index c3f701d..eeb8b30 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import datetime from pprint import pformat from typing import TYPE_CHECKING, Any, cast @@ -13,6 +14,7 @@ from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, + Initrec_FromDb, ) from wce_crm.logging import logger_back as logger from wce_crm.types import ( @@ -26,6 +28,7 @@ from wce_crm.types import ( ) if TYPE_CHECKING: + from wce_crm.data_models import AutoForm_State, Initrec from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId @@ -145,6 +148,241 @@ def initrec_company_update_initial_recording( conn.execute(stmt, data) +def initrec_company_to_db( + auto_form_data: Initrec, +) -> Initrec: + logger.debug("[AutoForm -- backend] Call database saving routine...") + + dump_data = copy.deepcopy(auto_form_data.form_data) + dump_data["geloescht"] = auto_form_data.geloescht + + with db.ENGINE.begin() as conn: + if auto_form_data.rec_id is None: + logger.debug("[AutoForm -- backend] Insert...") + + stmt = db.grunderfassung_unternehmen.insert().returning( + db.grunderfassung_unternehmen.c.un_id, + db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, + db.grunderfassung_unternehmen.c.geloescht, + ) + ret = conn.execute(stmt, dump_data) + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excactly one returned row" + data_from_db = from_db[0] + auto_form_data.rec_id = data_from_db["un_id"] + auto_form_data.geloescht = data_from_db["geloescht"] + auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] + + logger.debug("[AutoForm -- backend] Inserted InitRec successfully") + + else: + logger.debug("[AutoForm -- backend] Update...") + stmt = ( + db.beratung_vorgang.update() + .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.rec_id) + .returning( + db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, + db.grunderfassung_unternehmen.c.geloescht, + ) + ) + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = from_db[0] + + auto_form_data.geloescht = data_from_db["geloescht"] + auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] + + logger.debug( + "[AutoForm -- backend] Updated InitRec with ID %d successfully", + auto_form_data.rec_id, + ) + + return auto_form_data + + +def initrec_company_from_db( + id_: RecId, +) -> Initrec: + logger.debug("[AutoForm -- backend] Call database loading routine...") + stmt = db.grunderfassung_unternehmen.select().where( + db.grunderfassung_unternehmen.c.un_id == id_ + ) + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_from_db = dict(from_db[0]) + + geloescht = data_from_db["geloescht"] + del data_from_db["geloescht"] + + return Initrec( + rec_id=id_, + geloescht=geloescht, + form_data=data_from_db, + ) + + +def initrec_person_to_db( + auto_form_data: Initrec, +) -> Initrec: + logger.debug("[AutoForm -- backend] Call database saving routine...") + + dump_data = copy.deepcopy(auto_form_data.form_data) + dump_data["geloescht"] = auto_form_data.geloescht + + with db.ENGINE.begin() as conn: + if auto_form_data.rec_id is None: + logger.debug("[AutoForm -- backend] Insert...") + + stmt = db.grunderfassung_personen.insert().returning( + db.grunderfassung_personen.c.pers_id, + db.grunderfassung_personen.c.Metadaten_aktualisierung, + db.grunderfassung_personen.c.geloescht, + ) + ret = conn.execute(stmt, dump_data) + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excactly one returned row" + data_from_db = from_db[0] + auto_form_data.rec_id = data_from_db["un_id"] + auto_form_data.geloescht = data_from_db["geloescht"] + auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] + + logger.debug("[AutoForm -- backend] Inserted InitRec successfully") + + else: + logger.debug("[AutoForm -- backend] Update...") + stmt = ( + db.beratung_vorgang.update() + .where(db.grunderfassung_personen.c.pers_id == auto_form_data.rec_id) + .returning( + db.grunderfassung_personen.c.Metadaten_aktualisierung, + db.grunderfassung_personen.c.geloescht, + ) + ) + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = from_db[0] + + auto_form_data.geloescht = data_from_db["geloescht"] + auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] + + logger.debug( + "[AutoForm -- backend] Updated InitRec with ID %d successfully", + auto_form_data.rec_id, + ) + + return auto_form_data + + +def initrec_person_from_db( + id_: RecId, +) -> Initrec: + logger.debug("[AutoForm -- backend] Call database loading routine...") + stmt = db.grunderfassung_personen.select().where( + db.grunderfassung_personen.c.pers_id == id_ + ) + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_from_db = dict(from_db[0]) + + geloescht = data_from_db["geloescht"] + del data_from_db["geloescht"] + + return Initrec( + rec_id=id_, + geloescht=geloescht, + form_data=data_from_db, + ) + + +# def initrec_person_to_db( +# auto_form_data: Initrec, +# ) -> Initrec_FromDb: +# logger.debug("[AutoForm -- backend] Call database saving routine...") + +# dump_data = copy.deepcopy(auto_form_data.form_data) +# dump_data["geloescht"] = auto_form_data.geloescht + +# return_from_db: Initrec_FromDb + +# with db.ENGINE.begin() as conn: +# if auto_form_data.rec_id is None: +# # insert new "Vorgang" +# logger.debug("[AutoForm -- backend] Insert...") + +# stmt = db.grunderfassung_personen.insert().returning( +# db.grunderfassung_personen.c.pers_id, +# db.grunderfassung_personen.c.Metadaten_aktualisierung, +# db.grunderfassung_personen.c.geloescht, +# ) +# ret = conn.execute(stmt, dump_data) +# if ret.rowcount == 0: +# raise IOError("Entry was not inserted correctly") + +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excactly one returned row" +# data_from_db = from_db[0] +# return_from_db = Initrec_FromDb( +# rec_id=data_from_db["un_id"], +# geloescht=data_from_db["geloescht"], +# Metadaten_aktualisierung=data_from_db["Metadaten_aktualisierung"], +# ) + +# logger.debug("[AutoForm -- backend] Inserted InitRec successfully") + +# else: +# logger.debug("[AutoForm -- backend] Update...") +# stmt = ( +# db.beratung_vorgang.update() +# .where(db.grunderfassung_personen.c.pers_id == auto_form_data.rec_id) +# .returning( +# db.grunderfassung_personen.c.Metadaten_aktualisierung, +# db.grunderfassung_personen.c.geloescht, +# ) +# ) +# ret = conn.execute(stmt, dump_data) + +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excatly one returned row" +# data_from_db = from_db[0] + +# return_from_db = Initrec_FromDb( +# rec_id=auto_form_data.rec_id, +# geloescht=data_from_db["geloescht"], +# Metadaten_aktualisierung=data_from_db["Metadaten_aktualisierung"], +# ) + +# logger.debug( +# "[AutoForm -- backend] Updated InitRec with ID %d successfully", +# auto_form_data.rec_id, +# ) + +# return return_from_db + + def initrec_company_get_initial_recording( id_: RecId, ) -> dict[str, Any]: diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index f4e2dce..2894cf8 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -177,6 +177,20 @@ class AutoFormDelete(Protocol): ) -> None: ... +class AutoFormToDb(Protocol): + def __call__( + self, + auto_form_data: Initrec, + ) -> Initrec: ... + + +class AutoFormFromDb(Protocol): + def __call__( + self, + id_: int, + ) -> Initrec: ... + + @dc.dataclass(slots=True) class Session: user_id: UserId @@ -209,6 +223,8 @@ class Page_InitRecCompany_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) class AutoFormConfig: model: type[FlatBaseModel] + to_db: AutoFormToDb + from_db: AutoFormFromDb data_insert: AutoFormInsert data_update: AutoFormUpdate data_get: AutoFormGet @@ -225,6 +241,7 @@ class AutoForm_State(PageState[Module]): rec_id: RecId | None = None form_data: dict[str, Any] | None = None locked: bool + geloescht: bool = False @dc.dataclass(slots=True, kw_only=True) @@ -395,6 +412,19 @@ class FlatBaseModel(BaseModel): # ** InitRec +class Initrec_FromDb(BaseModel): + rec_id: RecId + geloescht: AwareDatetime | None + Metadaten_aktualisierung: AwareDatetime + form_data: dict[str, Any] + + +class Initrec(BaseModel): + rec_id: RecId | None + geloescht: AwareDatetime | None = None + form_data: dict[str, Any] + + class Grunderfassung_Unternehmen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime Metadaten_erstellung: AwareDatetime | None = None diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 37bb5d8..af92cce 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -86,6 +86,7 @@ from wce_crm.data_models import ( Beratungsgespraech_Vorgang, Grunderfassung_Personen, Grunderfassung_Unternehmen, + Initrec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_State, @@ -111,7 +112,7 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) -from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType +from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType, RecId if TYPE_CHECKING: from wce_crm.data_models import AutoForm_State, Module, WrapperModule @@ -1417,7 +1418,7 @@ class AutoForm(QWidget): self.main_layout.addWidget(self.registry_button) self.test_button = QPushButton("Initialisiere Laden") - self.test_button.clicked.connect(self._load_data) + self.test_button.clicked.connect(self._load_from_id) self.test_button.setFixedHeight(35) self.test_button.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed @@ -1535,9 +1536,9 @@ class AutoForm(QWidget): self.update_triggered.emit() self.reset_form() - def _load_data( + def _load_from_id( self, - lookup_id: int | None, + id_: RecId | None, ) -> None: if DEBUG_NO_DATABASE: return @@ -1545,28 +1546,33 @@ class AutoForm(QWidget): logger_auto_form.info("[Auto-Form] Load data method...") self.reset_form() - logger_auto_form.debug("[Auto-Form] Lookup ID: %s", lookup_id) + logger_auto_form.debug("[Auto-Form] Lookup ID: %s", id_) - if lookup_id is not None: + if id_ is not None: logger_auto_form.debug("Load from DB:") - loaded_data = self.cfg.data_get(lookup_id) + returned = self.cfg.from_db(id_) + # loaded_data = self.cfg.data_get(lookup_id) # TODO add path to avoid unnecessary loading (where the same ID is loaded # twice in a row) -- use an internal marker (not state-related) else: logger_auto_form.debug("[Auto-Form] Loading: Lookup ID NULL. Do nothing!") return + # logger_auto_form.debug( + # "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + # ) logger_auto_form.debug( - "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", + pformat(returned.form_data), ) - model = self.cfg.model(**loaded_data) + model = self.cfg.model(**returned.form_data) logger_auto_form.debug("[Auto-Form] Loaded to Pydantic.") logger_auto_form.debug("[Auto-Form] Convert to GUI structure...") form_data = model.to_gui() logger_auto_form.debug("[Auto-Form] Set form data...") logger_auto_form.debug("[Auto-Form] Form data:\n%s", pformat(form_data)) self._set_form_data(form_data) - self.STATE.rec_id = lookup_id + self.STATE.rec_id = id_ self._activate_delete() def reset_form(self) -> None: @@ -1608,7 +1614,7 @@ class AutoForm(QWidget): section: Literal["form", "locking"] | None, ) -> None: if section is None or section == "form": - self._load_data(self.STATE.rec_id) + self._load_from_id(self.STATE.rec_id) if section is None or section == "locking": if self.STATE.locked: @@ -1641,6 +1647,13 @@ class AutoForm(QWidget): return self.STATE + def _load_from_pydantic(self, data: Initrec) -> None: + self.STATE.rec_id = data.rec_id + self.STATE.form_data = data.form_data + self.STATE.geloescht = True if data.geloescht else False + + self._sync_state_to_GUI(None) # TODO change to correct loading + def save_data(self) -> None: self._disable_save() errors = self.validate() @@ -1679,15 +1692,27 @@ class AutoForm(QWidget): "[Auto-Form] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), ) + deleted_datetime: datetime.datetime | None = None + if self.STATE.geloescht: + deleted_datetime = datetime.datetime.now(datetime.UTC) try: - if self.STATE.rec_id is None: - logger_auto_form.debug("[Auto-Form] Insert triggered...") - init_rec_id = self.cfg.data_insert(db_data) - assert isinstance(init_rec_id, int) - self.STATE.rec_id = init_rec_id - else: - logger_auto_form.debug("[Auto-Form] Update triggered...") - self.cfg.data_update(self.STATE.rec_id, db_data) + to_db = Initrec( + rec_id=self.STATE.rec_id, + geloescht=deleted_datetime, + form_data=db_data, + ) + returned = self.cfg.to_db(to_db) + + self._load_from_pydantic(returned) + + # if self.STATE.rec_id is None: + # logger_auto_form.debug("[Auto-Form] Insert triggered...") + # init_rec_id = self.cfg.data_insert(db_data) + # assert isinstance(init_rec_id, int) + # self.STATE.rec_id = init_rec_id + # else: + # logger_auto_form.debug("[Auto-Form] Update triggered...") + # self.cfg.data_update(self.STATE.rec_id, db_data) self.update_triggered.emit() self._activate_delete() @@ -2876,6 +2901,8 @@ class Page_NewInitRec(QWidget): CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Unternehmen, + to_db=backend.initrec_company_to_db, + from_db=backend.initrec_company_from_db, data_insert=backend.initrec_company_insert_initial_recording, data_update=backend.initrec_company_update_initial_recording, data_get=backend.initrec_company_get_initial_recording, @@ -2889,6 +2916,8 @@ CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Personen, + to_db=backend.initrec_person_to_db, + from_db=backend.initrec_person_from_db, data_insert=backend.initrec_person_insert_initial_recording, data_update=backend.initrec_person_update_initial_recording, data_get=backend.initrec_person_get_initial_recording, -- 2.34.1 From c1ba3cf6e709ab0ddb706b891fa8ed5a0f322ceb Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 20 Jul 2026 15:48:36 +0200 Subject: [PATCH 40/74] added new data model principle to auto forms --- src/wce_crm/backend/backend.py | 25 +++---- src/wce_crm/data_models.py | 20 +++--- src/wce_crm/gui.py | 118 +++++++++++++++++++++------------ 3 files changed, 97 insertions(+), 66 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index eeb8b30..9b3ca5d 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -14,7 +14,7 @@ from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, - Initrec_FromDb, + Initrec, ) from wce_crm.logging import logger_back as logger from wce_crm.types import ( @@ -28,7 +28,6 @@ from wce_crm.types import ( ) if TYPE_CHECKING: - from wce_crm.data_models import AutoForm_State, Initrec from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId @@ -153,7 +152,7 @@ def initrec_company_to_db( ) -> Initrec: logger.debug("[AutoForm -- backend] Call database saving routine...") - dump_data = copy.deepcopy(auto_form_data.form_data) + dump_data = copy.deepcopy(auto_form_data.db_data) dump_data["geloescht"] = auto_form_data.geloescht with db.ENGINE.begin() as conn: @@ -174,7 +173,7 @@ def initrec_company_to_db( data_from_db = from_db[0] auto_form_data.rec_id = data_from_db["un_id"] auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] @@ -183,7 +182,7 @@ def initrec_company_to_db( else: logger.debug("[AutoForm -- backend] Update...") stmt = ( - db.beratung_vorgang.update() + db.grunderfassung_unternehmen.update() .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.rec_id) .returning( db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, @@ -196,8 +195,7 @@ def initrec_company_to_db( assert len(from_db) == 1, "expected excatly one returned row" data_from_db = from_db[0] - auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] @@ -229,7 +227,7 @@ def initrec_company_from_db( return Initrec( rec_id=id_, geloescht=geloescht, - form_data=data_from_db, + db_data=data_from_db, ) @@ -238,7 +236,7 @@ def initrec_person_to_db( ) -> Initrec: logger.debug("[AutoForm -- backend] Call database saving routine...") - dump_data = copy.deepcopy(auto_form_data.form_data) + dump_data = copy.deepcopy(auto_form_data.db_data) dump_data["geloescht"] = auto_form_data.geloescht with db.ENGINE.begin() as conn: @@ -259,7 +257,7 @@ def initrec_person_to_db( data_from_db = from_db[0] auto_form_data.rec_id = data_from_db["un_id"] auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] @@ -268,7 +266,7 @@ def initrec_person_to_db( else: logger.debug("[AutoForm -- backend] Update...") stmt = ( - db.beratung_vorgang.update() + db.grunderfassung_personen.update() .where(db.grunderfassung_personen.c.pers_id == auto_form_data.rec_id) .returning( db.grunderfassung_personen.c.Metadaten_aktualisierung, @@ -281,8 +279,7 @@ def initrec_person_to_db( assert len(from_db) == 1, "expected excatly one returned row" data_from_db = from_db[0] - auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.form_data["Metadaten_aktualisierung"] = data_from_db[ + auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] @@ -314,7 +311,7 @@ def initrec_person_from_db( return Initrec( rec_id=id_, geloescht=geloescht, - form_data=data_from_db, + db_data=data_from_db, ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 2894cf8..d47e5e4 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -225,10 +225,10 @@ class AutoFormConfig: model: type[FlatBaseModel] to_db: AutoFormToDb from_db: AutoFormFromDb - data_insert: AutoFormInsert - data_update: AutoFormUpdate - data_get: AutoFormGet - data_delete: AutoFormDelete + # data_insert: AutoFormInsert + # data_update: AutoFormUpdate + # data_get: AutoFormGet + # data_delete: AutoFormDelete form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = True @@ -412,17 +412,17 @@ class FlatBaseModel(BaseModel): # ** InitRec -class Initrec_FromDb(BaseModel): - rec_id: RecId - geloescht: AwareDatetime | None - Metadaten_aktualisierung: AwareDatetime - form_data: dict[str, Any] +# class Initrec_FromDb(BaseModel): +# rec_id: RecId +# geloescht: AwareDatetime | None +# Metadaten_aktualisierung: AwareDatetime +# form_data: dict[str, Any] class Initrec(BaseModel): rec_id: RecId | None geloescht: AwareDatetime | None = None - form_data: dict[str, Any] + db_data: dict[str, Any] class Grunderfassung_Unternehmen(FlatBaseModel): diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index af92cce..8dd1972 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1532,9 +1532,17 @@ class AutoForm(QWidget): def _delete_data(self) -> None: assert self.STATE.rec_id is not None, "deletion initialised despite no index set" - self.cfg.data_delete(self.STATE.rec_id) + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + if not confirm: + return + + self.STATE.geloescht = True + self.save_data() self.update_triggered.emit() - self.reset_form() def _load_from_id( self, @@ -1544,28 +1552,26 @@ class AutoForm(QWidget): return logger_auto_form.info("[Auto-Form] Load data method...") + logger_auto_form.debug("[Auto-Form] Lookup ID: %s", id_) self.reset_form() - logger_auto_form.debug("[Auto-Form] Lookup ID: %s", id_) - - if id_ is not None: - logger_auto_form.debug("Load from DB:") - returned = self.cfg.from_db(id_) - # loaded_data = self.cfg.data_get(lookup_id) - # TODO add path to avoid unnecessary loading (where the same ID is loaded - # twice in a row) -- use an internal marker (not state-related) - else: + if id_ is None: logger_auto_form.debug("[Auto-Form] Loading: Lookup ID NULL. Do nothing!") return + logger_auto_form.debug("Load from DB:") + returned = self.cfg.from_db(id_) + + # TODO add path to avoid unnecessary loading (where the same ID is loaded + # twice in a row) -- use an internal marker (not state-related) # logger_auto_form.debug( # "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) # ) logger_auto_form.debug( "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", - pformat(returned.form_data), + pformat(returned.db_data), ) - model = self.cfg.model(**returned.form_data) + model = self.cfg.model(**returned.db_data) logger_auto_form.debug("[Auto-Form] Loaded to Pydantic.") logger_auto_form.debug("[Auto-Form] Convert to GUI structure...") form_data = model.to_gui() @@ -1573,13 +1579,17 @@ class AutoForm(QWidget): logger_auto_form.debug("[Auto-Form] Form data:\n%s", pformat(form_data)) self._set_form_data(form_data) self.STATE.rec_id = id_ - self._activate_delete() + self.STATE.form_data = self._get_form_data() + logger_auto_form.debug( + "####### model dump vs get form data:\n%s\n\n%s", + pformat(model.model_dump(mode="python")), + pformat(self.STATE.form_data), + ) def reset_form(self) -> None: reset_form(self.widget_registry) self.STATE.rec_id = None self.STATE.form_data = None - self._activate_delete() def lock_form(self) -> None: self.STATE.locked = True @@ -1614,17 +1624,21 @@ class AutoForm(QWidget): section: Literal["form", "locking"] | None, ) -> None: if section is None or section == "form": - self._load_from_id(self.STATE.rec_id) + if not self.STATE.geloescht: + self._load_from_id(self.STATE.rec_id) + else: + self.reset_form() if section is None or section == "locking": if self.STATE.locked: if self.edit_buttons is not None: - self.edit_buttons.set_modify_state(False) + self.edit_buttons.set_modify_state(False, set_delete_btn=True) lock_form(self.widget_registry) else: if self.edit_buttons is not None: - self.edit_buttons.set_modify_state(True) + self.edit_buttons.set_modify_state(True, set_delete_btn=False) unlock_form(self.widget_registry) + self._activate_delete() def _sync_GUI_to_state(self) -> None: form_data = self._get_form_data() @@ -1647,14 +1661,46 @@ class AutoForm(QWidget): return self.STATE - def _load_from_pydantic(self, data: Initrec) -> None: + def _load_from_pydantic( + self, + data: Initrec, + ) -> None: self.STATE.rec_id = data.rec_id - self.STATE.form_data = data.form_data + self.STATE.form_data = None self.STATE.geloescht = True if data.geloescht else False self._sync_state_to_GUI(None) # TODO change to correct loading def save_data(self) -> None: + if self.STATE.rec_id is None and self.STATE.geloescht: + logger_auto_form.debug( + "[Auto-Form] This was never saved in the database " + "and is marked for deletion - ignore." + ) + return + elif self.STATE.rec_id is not None and self.STATE.geloescht: + logger_auto_form.debug( + "[Auto-Form] This was saved in the database " + "and is marked for deletion - fast path." + ) + deleted_datetime = datetime.datetime.now(datetime.UTC) + assert self.STATE.form_data, "cannot delete entry with uninitialised form data" + to_db = Initrec( + rec_id=self.STATE.rec_id, + geloescht=deleted_datetime, + db_data={}, + ) + returned = self.cfg.to_db(to_db) + self._load_from_pydantic(returned) + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Löschen erfolgreich", + "Der Eintrag wurde erfolgreich gelöscht", + ) + msg_box.exec() + + return + self._disable_save() errors = self.validate() @@ -1699,23 +1745,12 @@ class AutoForm(QWidget): to_db = Initrec( rec_id=self.STATE.rec_id, geloescht=deleted_datetime, - form_data=db_data, + db_data=db_data, ) returned = self.cfg.to_db(to_db) self._load_from_pydantic(returned) - - # if self.STATE.rec_id is None: - # logger_auto_form.debug("[Auto-Form] Insert triggered...") - # init_rec_id = self.cfg.data_insert(db_data) - # assert isinstance(init_rec_id, int) - # self.STATE.rec_id = init_rec_id - # else: - # logger_auto_form.debug("[Auto-Form] Update triggered...") - # self.cfg.data_update(self.STATE.rec_id, db_data) - self.update_triggered.emit() - self._activate_delete() except Exception as err: msg_box = get_message_box( QMessageBox.Icon.Warning, @@ -2790,6 +2825,11 @@ class MainPage_ClickableCell(ClickableCell): super().__init__(text=text) self.data_record = data_record + logger_gui.debug( + "Initialised ClickableCell on MainPage with data record:\n%s", + pformat(self.data_record), + ) + def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data_record) @@ -2903,10 +2943,6 @@ CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Unternehmen, to_db=backend.initrec_company_to_db, from_db=backend.initrec_company_from_db, - data_insert=backend.initrec_company_insert_initial_recording, - data_update=backend.initrec_company_update_initial_recording, - data_get=backend.initrec_company_get_initial_recording, - data_delete=backend.initrec_company_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", @@ -2918,10 +2954,6 @@ CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Personen, to_db=backend.initrec_person_to_db, from_db=backend.initrec_person_from_db, - data_insert=backend.initrec_person_insert_initial_recording, - data_update=backend.initrec_person_update_initial_recording, - data_get=backend.initrec_person_get_initial_recording, - data_delete=backend.initrec_person_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", @@ -3100,6 +3132,7 @@ class Page_InitRecCompany(QWidget): form_data=None, locked=self.STATE.locked, ) + logger_gui.debug(">>>>>>>>>>>>> AutoForm State:\n%s", pformat(auto_form_state)) self.auto_form.load_state(auto_form_state) if section is None or section == "comp_profile": @@ -4490,7 +4523,6 @@ class Page_Consulting(QWidget): "[Consulting Page] Pydantic model:\n%s", consulting_process ) - # !! should be placed in a try block to handle errors res = backend.page_consulting_to_db(consulting_process) if res.status != STATUS_HANDLER.SUCCESS: logger_page_consulting.error( @@ -5294,11 +5326,13 @@ class EditButtons(QWidget): def set_modify_state( self, enable_modification: bool, + set_delete_btn: bool = True, ) -> None: if self.modify_button is not None: self.modify_button.set_modify_state(enable_modification) self.save_btn.setEnabled(enable_modification) - self.delete_btn.setEnabled(enable_modification) + if set_delete_btn: + self.delete_btn.setEnabled(enable_modification) class MainWindow(QMainWindow): -- 2.34.1 From 284196f127c2ef54bef83b4508375210db7431e5 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 20 Jul 2026 16:16:31 +0200 Subject: [PATCH 41/74] set correct filter to show only not deleted entries --- src/wce_crm/backend/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 9b3ca5d..df7d911 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -699,7 +699,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: db.grunderfassung_unternehmen.c.un_id, db.grunderfassung_unternehmen.c.Partnersuche__un_suche, db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - ) + ).where(db.grunderfassung_unternehmen.c.geloescht.is_(None)) with db.ENGINE.connect() as conn: res = conn.execute(stmt) @@ -736,7 +736,7 @@ def _main_page_get_person_list() -> list[MainPageEntry]: db.grunderfassung_personen.c.Stammdaten__vorname, db.grunderfassung_personen.c.Stammdaten__name, db.grunderfassung_personen.c.Metadaten_aktualisierung, - ) + ).where(db.grunderfassung_personen.c.geloescht.is_(None)) with db.ENGINE.connect() as conn: res = conn.execute(stmt) -- 2.34.1 From 0fce42b7887066d9581fa8dc8d80599ee23fff73 Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 21 Jul 2026 08:54:41 +0200 Subject: [PATCH 42/74] change to timestamps for linking table for companies and persons --- data_model/project_db_schema.aml | 4 ++-- src/wce_crm/db.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 476670f..2738be5 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -77,8 +77,8 @@ Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehör id int pk {autoIncrement} un_id int -> Grunderfassung_Unternehmen(un_id) pers_id int -> Grunderfassung_Personen(pers_id) - gueltig_ab date - gueltig_bis date nullable + gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` + gueltig_bis "timestamp with time zone" nullable Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein (pauschal) oder beide (individual) gebunden sein diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index aaecaf0..c990f04 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -380,8 +380,6 @@ zuordnung_personen_unternehmen: sql.Table = Table( "gueltig_bis", UTCDateTime, nullable=True, - default=lambda: datetime.datetime.now(datetime.UTC), - onupdate=lambda: datetime.datetime.now(datetime.UTC), ), ) -- 2.34.1 From 482ae436eb919f802434ccfc11a62f6cfe8a8f0f Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 21 Jul 2026 11:00:46 +0200 Subject: [PATCH 43/74] added deletion for consulting with GUI logic --- src/wce_crm/backend/backend.py | 48 ++++++++++++- src/wce_crm/data_models.py | 2 +- src/wce_crm/gui.py | 123 ++++++++++++++++++++++++++++----- 3 files changed, 151 insertions(+), 22 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index df7d911..2b0c0b4 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -564,6 +564,48 @@ def page_consulting_to_db( return consultation_data +@wrap_result(10) +def page_consulting_delete( + cons_id: ConsId, +) -> None: + timestamp_deleted = datetime.datetime.now(datetime.UTC) + dump_data: dict[str, Any] = {"geloescht": timestamp_deleted} + + with db.ENGINE.begin() as conn: + stmt = db.beratung_vorgang.update().where(db.beratung_vorgang.c.vorgang_id == cons_id) + conn.execute(stmt, dump_data) + + stmt = db.beratung_einzelberatung.update().where( + db.beratung_einzelberatung.c.vorgang_id == cons_id, + db.beratung_einzelberatung.c.geloescht.is_(None), + ) + conn.execute(stmt, dump_data) + return + + stmt = db.beratung_einzelberatung.select().where( + db.beratung_einzelberatung.c.vorgang_id == cons_id, + db.beratung_einzelberatung.c.geloescht.is_(None), + ) + ret = conn.execute(stmt) + cons_sessions = ret.mappings().all() + # empty results possible + if not cons_sessions: + logger.debug("[Consulting Page] No sessions, return directly...") + return + + rows_for_db_update: list[dict[str, Any]] = [] + for row_data in cons_sessions: + row_data_dump = copy.deepcopy(dump_data) + row_data_dump["b_beratung_id"] = row_data["beratung_id"] + rows_for_db_update.append(row_data_dump) + + if rows_for_db_update: + stmt = db.beratung_einzelberatung.update().where( + db.beratung_einzelberatung.c.beratung_id == sql.bindparam("b_beratung_id") + ) + conn.execute(stmt, rows_for_db_update) + + @wrap_result(11) def page_consulting_from_db( cons_id: ConsId, @@ -611,12 +653,12 @@ def page_consulting_from_db( db.beratung_einzelberatung.c.geloescht.is_(None), ) ret = conn.execute(stmt) + cons_sessions = ret.mappings().all() # empty results possible - if ret.rowcount == 0: + if not cons_sessions: logger.debug("[Consulting Page] No sessions, return directly...") return consultation_data - cons_sessions = ret.mappings() for session in cons_sessions: cons_session_pydantic = Beratungsgespraech_Einzelgespraech( beratung_id=session["beratung_id"], @@ -649,7 +691,7 @@ def companyprofile_page_get_consultations( db.beratung_vorgang.c.aktualisiert, db.beratung_vorgang.c.titel, db.beratung_vorgang.c.beratungs_typ, - ).where(db.beratung_vorgang.c.un_id == un_id) + ).where(db.beratung_vorgang.c.un_id == un_id, db.beratung_vorgang.c.geloescht.is_(None)) with db.ENGINE.connect() as conn: res = conn.execute(stmt) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index d47e5e4..94d30e0 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -262,7 +262,7 @@ class Page_Consulting_State(PageState[Module]): cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( default_factory=list ) - ist_geloescht: bool = False + geloescht: bool = False locked: bool diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 8dd1972..08b2b60 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1590,6 +1590,7 @@ class AutoForm(QWidget): reset_form(self.widget_registry) self.STATE.rec_id = None self.STATE.form_data = None + self.STATE.geloescht = False def lock_form(self) -> None: self.STATE.locked = True @@ -1742,6 +1743,7 @@ class AutoForm(QWidget): if self.STATE.geloescht: deleted_datetime = datetime.datetime.now(datetime.UTC) try: + # TODO change to result pattern to_db = Initrec( rec_id=self.STATE.rec_id, geloescht=deleted_datetime, @@ -3706,11 +3708,14 @@ class Page_CompanyProfile(QWidget): widget.deleteLater() def _update_consultations(self) -> None: - assert self.STATE.rec_id is not None, ( - "cannot update consultations if RecID is not set" - ) + if self.STATE.rec_id is None: + return consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.rec_id) + logger_gui.debug( + "[Page -- Company Profile] Consultations: %s", pformat(consultations) + ) + self._clear_consultations() for entry in consultations.pauschal: @@ -4179,13 +4184,26 @@ class Page_Consulting(QWidget): def _delete_data(self) -> None: logger_page_consulting.debug("[Consulting Page] Call data delete...") + assert self.STATE.vorgang_id is not None, "deletion initialised despite no index set" + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + if not confirm: + return + + self.STATE.geloescht = True + self._delete_entry() + self.update_triggered.emit() def _disable_save(self) -> None: self.save_btn.setEnabled(False) self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) def _activate_delete(self) -> None: - if self.STATE.un_id is not None or self.STATE.pers_id is not None: + # if self.STATE.un_id is not None or self.STATE.pers_id is not None: + if self.STATE.vorgang_id is not None: self.delete_btn.setEnabled(True) else: self.delete_btn.setEnabled(False) @@ -4241,7 +4259,7 @@ class Page_Consulting(QWidget): except ValueError: return - self.load_database(db_index) + self._load_database(db_index) def _open_linking_dialogue(self) -> None: logger_page_consulting.info("[Consulting Page] Linking dialogue triggered") @@ -4271,10 +4289,21 @@ class Page_Consulting(QWidget): self.info_banners.append(banner) self.info_banners_layout.addWidget(banner) + def _reset(self) -> None: + self.STATE.vorgang_id = None + self.STATE.cons_sessions.clear() + self.STATE.geloescht = False + def _sync_state_to_GUI( self, - section: Literal["cons_type", "banners", "table", "locking"] | None, + section: Literal["content", "cons_type", "banners", "table", "locking"] | None, ) -> None: + if section is None or section == "content": + if not self.STATE.geloescht: + self._load_database(self.STATE.vorgang_id) + else: + self._reset() + if section is None or section == "cons_type" or section == "locking": match self.STATE.beratungs_typ: case ConsultingType.PAUSCHAL: @@ -4333,10 +4362,11 @@ class Page_Consulting(QWidget): if section is None or section == "locking": if self.STATE.locked: self.table_sessions.lock() - self.edit_buttons.set_modify_state(False) + self.edit_buttons.set_modify_state(False, set_delete_btn=True) else: self.table_sessions.unlock() - self.edit_buttons.set_modify_state(True) + self.edit_buttons.set_modify_state(True, set_delete_btn=False) + self._activate_delete() def _sync_GUI_to_state(self) -> None: # call to child modules' syncing method not needed because their syncing method is @@ -4361,12 +4391,19 @@ class Page_Consulting(QWidget): self._sync_state_to_GUI(None) - def load_database( + def _load_database( self, - cons_id: ConsId, + cons_id: ConsId | None, ) -> None: - # TODO add routine # load data model from database + logger_auto_form.info("[Consulting Page] Load data method...") + logger_auto_form.debug("[Consulting Page] Lookup ID: %s", cons_id) + self._reset() + + if cons_id is None: + logger_auto_form.debug("[Consulting Page] Loading: Lookup ID NULL. Do nothing!") + return + res = backend.page_consulting_from_db(cons_id) if res.status != STATUS_HANDLER.SUCCESS: logger_page_consulting.error( @@ -4394,19 +4431,20 @@ class Page_Consulting(QWidget): # instantiate state from data model consulting_process = res.unwrap() - self._load_from_pydantic(consulting_process) + self._load_from_pydantic(consulting_process, sync_to_GUI=False) def _load_from_pydantic( self, data: Beratungsgespraech_Vorgang, update_only: bool = False, + sync_to_GUI: bool = True, ) -> None: self.STATE.vorgang_id = data.vorgang_id self.STATE.beratungs_typ = ConsultingType(data.beratungs_typ) self.STATE.un_id = data.un_id self.STATE.pers_id = data.pers_id self.STATE.titel = data.titel - self.STATE.ist_geloescht = data.geloescht is not None + self.STATE.geloescht = data.geloescht is not None self.STATE.locked = True if update_only: @@ -4438,15 +4476,63 @@ class Page_Consulting(QWidget): ) self.STATE.cons_sessions.append(cons_session_state) - self._sync_state_to_GUI(None) + if sync_to_GUI: + self._sync_state_to_GUI(None) def get_state(self) -> Page_Consulting_State: self._sync_GUI_to_state() return self.STATE + def _delete_entry(self) -> None: + assert self.STATE.vorgang_id is not None and self.STATE.geloescht + logger_auto_form.debug( + "[Consulting Page] This was saved in the database " + "and is marked for deletion - fast path." + ) + res = backend.page_consulting_delete(self.STATE.vorgang_id) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "deletion procedure." + ), + stack_info=True, + ) + + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Löschen fehlgeschlagen", + ( + "Beim Löschen der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return + + logger_page_consulting.info("[Consulting Page] Data deleted successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Löschen erfolgreich", + "Die Daten wurden erfolgreich gelöscht", + ) + msg_box.exec() + self._sync_state_to_GUI(None) + def save_data(self) -> None: logger_page_consulting.debug("[Consulting Page] Call saving data...") + if self.STATE.vorgang_id is None and self.STATE.geloescht: + logger_auto_form.debug( + "[Consulting Page] This was never saved in the database " + "and is marked for deletion - ignore." + ) + return + self._disable_save() errors = self.validate() @@ -4460,9 +4546,9 @@ class Page_Consulting(QWidget): logger_page_consulting.info("[Consulting Page] Success! All data is valid") logger_page_consulting.info("[Consulting Page] Get form data call...") - state = self.get_state() + self._sync_GUI_to_state() - logger_page_consulting.debug("[Consulting Page] Get state:\n%s", state) + logger_page_consulting.debug("[Consulting Page] Current state:\n%s", self.STATE) try: logger_page_consulting.debug("[Consulting Page] Call Pydantic...") @@ -4470,7 +4556,7 @@ class Page_Consulting(QWidget): pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = [] timestamp_deleted: datetime.datetime | None = None - if self.STATE.ist_geloescht: + if self.STATE.geloescht: timestamp_deleted = datetime.datetime.now(datetime.UTC) for row in self.STATE.cons_sessions: @@ -4553,6 +4639,7 @@ class Page_Consulting(QWidget): "[Consulting Page] Load state from modified Pydantic model" ) self._load_from_pydantic(updated_consulting_process, update_only=True) + self.update_triggered.emit() logger_page_consulting.info("[Consulting Page] Data saved successfully") msg_box = get_message_box( @@ -4561,7 +4648,7 @@ class Page_Consulting(QWidget): "Die Daten wurden erfolgreich gespeichert", ) msg_box.exec() - self.update_triggered.emit() + # self._activate_delete() finally: # always re-enable save, even if error occurred -- 2.34.1 From 7bfbdc731b0407739adbdb84ebad11ec4f67a925 Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 21 Jul 2026 16:08:32 +0200 Subject: [PATCH 44/74] prepare dialog for linking open consulting processes --- src/wce_crm/backend/backend.py | 190 ++++++++++--------- src/wce_crm/data_models.py | 8 + src/wce_crm/db.py | 41 +++- src/wce_crm/gui.py | 334 ++++++++++++++++++++++++++++++++- src/wce_crm/types.py | 12 ++ 5 files changed, 493 insertions(+), 92 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 2b0c0b4..d23b884 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -24,6 +24,8 @@ from wce_crm.types import ( ConsultingType, ContactPersonInfo, InitRecType, + LinkingConsultationsEntryCompany, + LinkingConsultationsEntryPerson, MainPageEntry, ) @@ -315,71 +317,6 @@ def initrec_person_from_db( ) -# def initrec_person_to_db( -# auto_form_data: Initrec, -# ) -> Initrec_FromDb: -# logger.debug("[AutoForm -- backend] Call database saving routine...") - -# dump_data = copy.deepcopy(auto_form_data.form_data) -# dump_data["geloescht"] = auto_form_data.geloescht - -# return_from_db: Initrec_FromDb - -# with db.ENGINE.begin() as conn: -# if auto_form_data.rec_id is None: -# # insert new "Vorgang" -# logger.debug("[AutoForm -- backend] Insert...") - -# stmt = db.grunderfassung_personen.insert().returning( -# db.grunderfassung_personen.c.pers_id, -# db.grunderfassung_personen.c.Metadaten_aktualisierung, -# db.grunderfassung_personen.c.geloescht, -# ) -# ret = conn.execute(stmt, dump_data) -# if ret.rowcount == 0: -# raise IOError("Entry was not inserted correctly") - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excactly one returned row" -# data_from_db = from_db[0] -# return_from_db = Initrec_FromDb( -# rec_id=data_from_db["un_id"], -# geloescht=data_from_db["geloescht"], -# Metadaten_aktualisierung=data_from_db["Metadaten_aktualisierung"], -# ) - -# logger.debug("[AutoForm -- backend] Inserted InitRec successfully") - -# else: -# logger.debug("[AutoForm -- backend] Update...") -# stmt = ( -# db.beratung_vorgang.update() -# .where(db.grunderfassung_personen.c.pers_id == auto_form_data.rec_id) -# .returning( -# db.grunderfassung_personen.c.Metadaten_aktualisierung, -# db.grunderfassung_personen.c.geloescht, -# ) -# ) -# ret = conn.execute(stmt, dump_data) - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excatly one returned row" -# data_from_db = from_db[0] - -# return_from_db = Initrec_FromDb( -# rec_id=auto_form_data.rec_id, -# geloescht=data_from_db["geloescht"], -# Metadaten_aktualisierung=data_from_db["Metadaten_aktualisierung"], -# ) - -# logger.debug( -# "[AutoForm -- backend] Updated InitRec with ID %d successfully", -# auto_form_data.rec_id, -# ) - -# return return_from_db - - def initrec_company_get_initial_recording( id_: RecId, ) -> dict[str, Any]: @@ -580,30 +517,6 @@ def page_consulting_delete( db.beratung_einzelberatung.c.geloescht.is_(None), ) conn.execute(stmt, dump_data) - return - - stmt = db.beratung_einzelberatung.select().where( - db.beratung_einzelberatung.c.vorgang_id == cons_id, - db.beratung_einzelberatung.c.geloescht.is_(None), - ) - ret = conn.execute(stmt) - cons_sessions = ret.mappings().all() - # empty results possible - if not cons_sessions: - logger.debug("[Consulting Page] No sessions, return directly...") - return - - rows_for_db_update: list[dict[str, Any]] = [] - for row_data in cons_sessions: - row_data_dump = copy.deepcopy(dump_data) - row_data_dump["b_beratung_id"] = row_data["beratung_id"] - rows_for_db_update.append(row_data_dump) - - if rows_for_db_update: - stmt = db.beratung_einzelberatung.update().where( - db.beratung_einzelberatung.c.beratung_id == sql.bindparam("b_beratung_id") - ) - conn.execute(stmt, rows_for_db_update) @wrap_result(11) @@ -682,6 +595,105 @@ def page_consulting_from_db( # // consulting page interaction +def page_consulting_linking_companies( + un_id: RecId | None, +) -> list[LinkingConsultationsEntryCompany]: + with db.ENGINE.connect() as conn: + q_relevant_companies = sql.select( + db.grunderfassung_unternehmen.c.un_id, + db.grunderfassung_unternehmen.c.Partnersuche__un_suche, + ).where( + db.grunderfassung_unternehmen.c.geloescht.is_(None), + db.grunderfassung_unternehmen.c.Partnersuche__un_suche.is_not(None), + ) + if un_id is not None: + q_relevant_companies = q_relevant_companies.where( + db.grunderfassung_unternehmen.c.un_id == un_id + ) + + q_relevant_companies = q_relevant_companies.subquery("relevant_companies") + + stmt = sql.select( + q_relevant_companies.c.un_id, + db.ext_crm_master_attach.c.ma_unternehmensname, + ).select_from( + q_relevant_companies.join( + db.ext_crm_master_attach, + q_relevant_companies.c.Partnersuche__un_suche + == db.ext_crm_master_attach.c.ma_id, + ) + ) + + ret = conn.execute(stmt) + company_names = ret.mappings().all() + if not company_names: + raise ValueError(f"No companies found for 'un_id': {un_id}") + + logger.debug("[Page Consulting -- Linking] Retrieved company names:\n%s", company_names) + + linking_entries: list[LinkingConsultationsEntryCompany] = [] + for entry in company_names: + linking_entries.append( + LinkingConsultationsEntryCompany( + un_id=entry["un_id"], + display_name=entry["ma_unternehmensname"], + ) + ) + + return linking_entries + + +def page_consulting_linking_persons( + un_id: RecId | None, +) -> list[LinkingConsultationsEntryPerson]: + with db.ENGINE.connect() as conn: + # if un_id None: list all persons + # else: list just the persons associated with the company + + person_select = sql.select( + db.grunderfassung_personen.c.pers_id, + db.grunderfassung_personen.c.Stammdaten__name, + db.grunderfassung_personen.c.Stammdaten__vorname, + db.grunderfassung_personen.c.Stammdaten__ort, + ) + + if un_id is not None: + person_select = person_select.select_from( + db.grunderfassung_personen.join( + db.zuordnung_personen_unternehmen, + db.grunderfassung_personen.c.pers_id + == db.zuordnung_personen_unternehmen.c.pers_id, + ) + ).where(db.zuordnung_personen_unternehmen.c.un_id == un_id) + + ret = conn.execute(person_select) + person_names = ret.mappings().all() + if not person_names: + logger.warning(f"No persons found for 'un_id': {un_id}") + return [] + + logger.debug("[Page Consulting -- Linking] Retrieved person names:\n%s", person_names) + + linking_entries: list[LinkingConsultationsEntryPerson] = [] + for entry in person_names: + first_name = entry["Stammdaten__vorname"] + last_name = entry["Stammdaten__name"] + location_city = entry["Stammdaten__ort"] + + display_name = f"{first_name} {last_name}" + if location_city: + display_name += f" (Ort: {location_city})" + + linking_entries.append( + LinkingConsultationsEntryPerson( + pers_id=entry["pers_id"], + display_name=display_name, + ) + ) + + return linking_entries + + def companyprofile_page_get_consultations( un_id: RecId, ) -> CompanyProfileConsultations: diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 94d30e0..b444340 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -266,6 +266,14 @@ class Page_Consulting_State(PageState[Module]): locked: bool +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Linking_State(PageState[Module]): + session: Session + un_id: RecId | None = None + pers_id: RecId | None = None + beratungs_typ: ConsultingType + + @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_ConsultingSession_State(PageState[Module]): session: Session diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index c990f04..737ed5f 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -7,7 +7,7 @@ from pathlib import Path import polars as pl import sqlalchemy as sql -from sqlalchemy import Column, String, Table, TypeDecorator +from sqlalchemy import Column, String, Table, TypeDecorator, event from wce_crm import constants from wce_crm import types as t @@ -63,6 +63,14 @@ MD_MAIN = sql.MetaData() ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") +@event.listens_for(ENGINE, "connect") +def attach_second_db(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + # 'kontaktliste' is the alias for the second DB + cursor.execute(f"ATTACH DATABASE '{constants.Config.DB_PATH_CRM}' AS kontaktliste") + cursor.close() + + ext_crm_master: sql.Table = Table( "Master", MD_CRM, @@ -247,6 +255,37 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) +ext_crm_master_attach: sql.Table = Table( + "Master", + MD_CRM, + Column("ma_id", sql.Integer, nullable=False, unique=True), + Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sql.Text, nullable=True), + Column("ma_branche", sql.Text, nullable=True), + Column("ma_strasse", sql.Text, nullable=True), + Column("ma_hausnummer", sql.Text, nullable=True), + Column("ma_plz", sql.Text, nullable=True), + Column("ma_ort", sql.Text, nullable=True), + Column("ma_plz_postfach", sql.Text, nullable=True), + Column("ma_postfach", sql.Text, nullable=True), + Column("ma_website", sql.Text, nullable=True), + Column("ma_mail", sql.Text, nullable=True), + Column("ma_telefonnummer", sql.Text, nullable=True), + Column("ma_faxnummer", sql.Text, nullable=True), + Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), + Column("ma_sollprozess", sql.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), + Column("ma_quelle_information", sql.Text, nullable=True), + Column("ma_bemerkung", sql.Text, nullable=True), + Column("ma_kontakt", sql.Boolean, nullable=True), + Column("ma_schlagworte", sql.Text, nullable=True), + Column("ma_archiviert", sql.Boolean, nullable=True, default=False), + schema="kontaktliste", +) + + grunderfassung_unternehmen: sql.Table = Table( "grunderfassung_unternehmen", MD_MAIN, diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 08b2b60..f0fa05e 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -51,6 +51,8 @@ from PySide6.QtWidgets import ( QComboBox, QCompleter, QDateEdit, + QDialog, + QDialogButtonBox, QFormLayout, QFrame, QGridLayout, @@ -60,6 +62,8 @@ from PySide6.QtWidgets import ( QLabel, QLayout, QLineEdit, + QListWidget, + QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit, @@ -89,6 +93,7 @@ from wce_crm.data_models import ( Initrec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, + Page_Consulting_Linking_State, Page_Consulting_State, Page_Consulting_Table_State, Page_InitRecCompany_State, @@ -115,8 +120,17 @@ from wce_crm.logging import ( from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType, RecId if TYPE_CHECKING: - from wce_crm.data_models import AutoForm_State, Module, WrapperModule - from wce_crm.types import ConsId, ExtMaId + from wce_crm.data_models import ( + AutoForm_State, + Module, + WrapperModule, + ) + from wce_crm.types import ( + ConsId, + ExtMaId, + LinkingConsultationsEntryCompany, + LinkingConsultationsEntryPerson, + ) K = TypeVar("K") V = TypeVar("V") @@ -2973,6 +2987,7 @@ class Page_InitRecCompany(QWidget): back_requested = Signal() # back button update_main_page = Signal() # form saved (data changed for front page) company_profile_requested = Signal(Page_CompanyProfile_State) + initrec_person_requested = Signal(Page_InitRecPerson_State) # TODO add corresponding call def __init__( self, @@ -3021,12 +3036,20 @@ class Page_InitRecCompany(QWidget): ) self.comp_profile_btn.setFixedHeight(50) + self.initrec_person_btn = QPushButton("Individualperson erfassen") + self.initrec_person_btn.clicked.connect(self._request_inirec_person) + self.initrec_person_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.initrec_person_btn.setFixedHeight(50) + header_layout.setSpacing(5) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) header_layout.addSpacing(15) header_layout.addWidget(self.comp_profile_btn) + header_layout.addWidget(self.initrec_person_btn) vert_layout.addWidget(header_container) # --- BANNER --- @@ -3119,6 +3142,18 @@ class Page_InitRecCompany(QWidget): logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) self.company_profile_requested.emit(req_state) + def _request_inirec_person(self) -> None: + assert self.STATE.un_id is not None, ( + "requested initrec of person for a company which is not known (no ID)" + ) + req_state = Page_InitRecPerson_State( + session=self.STATE.session, + pers_id=None, + locked=False, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.initrec_person_requested.emit(req_state) + def reset(self) -> None: self.auto_form.reset_form() @@ -4088,6 +4123,13 @@ class Page_Consulting(QWidget): db_index_layout.addWidget(db_index_set_button) container_layout.addLayout(db_index_layout) + linking_button = QPushButton("Verlinkungsdialog öffnen") + linking_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + linking_button.clicked.connect(self._open_linking_dialogue) + container_layout.addWidget(linking_button) + separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -4264,6 +4306,34 @@ class Page_Consulting(QWidget): def _open_linking_dialogue(self) -> None: logger_page_consulting.info("[Consulting Page] Linking dialogue triggered") + # Dialog erstellen und anzeigen + dialog_state = Page_Consulting_Linking_State( + session=self.STATE.session, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + beratungs_typ=self.STATE.beratungs_typ, + ) + dialog = Page_Consulting_LinkingDialog(dialog_state, parent=self) + + # .exec() öffnet das Fenster modal (blockiert das Hauptfenster) + if dialog.exec() == QDialog.DialogCode.Accepted: + # Hier hast du am Ende beide (oder nur eine) Entitäten auswertbar vorliegen: + firma = dialog.gewaehltes_unternehmen + person = dialog.gewaehlte_person + + comp_name = firma["name"] if firma else "Keine" + person_name = person["name"] if person else "Keine" + logger_page_consulting.info( + "[Consulting Page] Firme gewählt: %s, Person gewählt: %s", + comp_name, + person_name, + ) + + # 1. TODO: Datenbank-Update ausführen (Fremdschlüssel setzen) + # 2. Banner ausblenden: self.banner.hide() + else: + logger_page_consulting.info("[Consulting Page] Verknüpfung abgebrochen.") + def _clear_info_banners(self) -> None: while self.info_banners: banner = self.info_banners[0] @@ -4663,6 +4733,265 @@ class Page_Consulting(QWidget): self._sync_state_to_GUI("locking") +class Page_Consulting_LinkingDialog(QDialog): + def __init__( + self, + state: Page_Consulting_Linking_State, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.STATE = state + + self.setWindowTitle("Beziehung verknüpfen") + self.resize(700, 450) + + # firmen_aus_db = [{"id": 1, "name": "Siemens AG"}, {"id": 2, "name": "Bosch GmbH"}] + # personen_aus_db = [ + # { + # "id": 50, + # "name": "Max Mustermann", + # "unternehmen_id": 1, + # "firma_name": "Siemens AG", + # }, + # { + # "id": 51, + # "name": "Erika Muster", + # "unternehmen_id": 1, + # "firma_name": "Siemens AG", + # }, + # { + # "id": 52, + # "name": "Peter Müller", + # "unternehmen_id": 2, + # "firma_name": "Bosch GmbH", + # }, + # { + # "id": 53, + # "name": "Freiberufler Frank", + # "unternehmen_id": None, + # "firma_name": None, + # }, + # ] + + # self.all_unternehmen = firmen_aus_db + # self.all_personen = personen_aus_db + + # # Rückgabewerte + # self.gewaehltes_unternehmen = None + # self.gewaehlte_person = None + + # --- UI LAYOUT --- + main_layout = QVBoxLayout(self) + column_layout = QHBoxLayout() + + # left column: companies + self.vbox_companies = QVBoxLayout() + self.vbox_companies.addWidget(QLabel("Unternehmen wählen (Optional):")) + self.btn_clear_company = QPushButton("Auswahl aufheben") + self.list_companies = QListWidget() + self.vbox_companies.addWidget(self.list_companies) + self.vbox_companies.addWidget(self.btn_clear_company) + + # right column: persons + self.vbox_persons = QVBoxLayout() + self.vbox_persons.addWidget(QLabel("Person wählen (Optional):")) + self.btn_clear_person = QPushButton("Auswahl aufheben") + self.list_persons = QListWidget() + self.vbox_persons.addWidget(self.list_persons) + self.vbox_persons.addWidget(self.btn_clear_person) + + column_layout.addLayout(self.vbox_companies) + column_layout.addLayout(self.vbox_persons) + main_layout.addLayout(column_layout) + + # Unten: OK / Abbrechen Buttons + self.button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self.ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok) + self.ok_btn.setText("Verknüpfung speichern") + self.ok_btn.setEnabled(False) + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen") + main_layout.addWidget(self.button_box) + + # initial loading + # self._load_companies() + # self._load_persons() + + # list_style = """ + # QListWidget::item:selected { + # background-color: #0078D4; /* Blau, wenn die Liste aktiv fokussiert ist */ + # color: white; + # } + # QListWidget::item:selected:!active { + # background-color: #CCE4F7; /* Gut sichtbares Hellblau, wenn die Liste den Fokus verliert */ + # color: black; + # } + # QListWidget::item:hover { + # background-color: #F0F0F0; /* Dezentes Grau beim Drüberfahren */ + # } + # """ + # self.list_companies.setStyleSheet(list_style) + # self.list_persons.setStyleSheet(list_style) + + # signals + self.list_companies.itemSelectionChanged.connect(self._company_selected) + self.list_persons.itemSelectionChanged.connect(self._person_selected) + + self.btn_clear_company.clicked.connect(self._clear_company_selection) + self.btn_clear_person.clicked.connect(self._clear_person_selection) + + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self._sync_state_to_GUI(reset=True) + + def _enable_person_selection( + self, + enable: bool, + ) -> None: + logger_page_consulting.debug(">>>>>>> CALL ENABLE: %s", enable) + for i in range(self.vbox_persons.count()): + item = self.vbox_persons.itemAt(i) + if not item: + continue + widget = item.widget() + logger_page_consulting.debug(">>>>>>> Type widget: %s", type(widget)) + if isinstance(widget, QWidget): + logger_page_consulting.debug(">>>>>>> Widget (enable=%s): %s", enable, widget) + widget.setEnabled(enable) + + def _reset(self) -> None: + self._clear_company_selection() + self._clear_person_selection() + + def _sync_state_to_GUI( + self, + reset: bool, + section: Literal["person_selection", "validation"] | None = None, + ) -> None: + if reset: + self._reset() + self._load_companies() + + if section is None or section == "person_selection": + if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: + self._enable_person_selection(False) + elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: + self._enable_person_selection(True) + self.list_persons.blockSignals(True) + self._load_persons(self.STATE.un_id) + self.list_persons.blockSignals(False) + + if section is None or section == "validation": + self._validate() + + def _validate(self) -> None: + valid: bool = False + + if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: + assert self.STATE.pers_id is None + if self.STATE.un_id is not None: + valid = True + elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: + if self.STATE.pers_id is not None: + valid = True + else: + RuntimeError("Undefined consulting type") + + if valid: + self.ok_btn.setEnabled(True) + else: + self.ok_btn.setEnabled(False) + + def _load_companies(self) -> None: + self.list_companies.clear() + companies = backend.page_consulting_linking_companies(un_id=self.STATE.un_id) + logger_page_consulting.debug( + "[Page Consulting -- Linking] Retrieved company entries:\n%s", companies + ) + + for company in companies: + item = QListWidgetItem(company.display_name) + item.setData(Qt.ItemDataRole.UserRole, company) + self.list_companies.addItem(item) + + def _load_persons( + self, + un_id: RecId | None, + ) -> None: + """Lädt Personen. Wenn firma_id übergeben wird, wird gefiltert.""" + self.list_persons.clear() + + persons = backend.page_consulting_linking_persons(un_id) + logger_page_consulting.debug( + "[Page Consulting -- Linking] Retrieved person entries:\n%s", persons + ) + + for person in persons: + item = QListWidgetItem(person.display_name) + item.setData(Qt.ItemDataRole.UserRole, person) + self.list_persons.addItem(item) + + # --- LOGIK --- + def _company_selected(self) -> None: + items = self.list_companies.selectedItems() + if not items: + return + + company = cast( + "LinkingConsultationsEntryCompany", items[0].data(Qt.ItemDataRole.UserRole) + ) + self.STATE.un_id = company.un_id + + logger_page_consulting.debug( + "[Page Consulting -- Linking] Company selected. State is:\n%s", + pformat(self.STATE), + ) + self._sync_state_to_GUI(reset=False) + # if self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: + # self.list_persons.blockSignals(True) + # self._load_persons(un_id=self.STATE.un_id) + # self.list_persons.blockSignals(False) + + def _person_selected(self) -> None: + items = self.list_persons.selectedItems() + if not items: + return + + person = cast( + "LinkingConsultationsEntryPerson", items[0].data(Qt.ItemDataRole.UserRole) + ) + self.STATE.pers_id = person.pers_id + self._sync_state_to_GUI(reset=False, section="validation") + + # TODO this backlink is a really good idea and should be implemented + # if person["unternehmen_id"]: + # self.list_companies.blockSignals(True) + # + # for i in range(self.list_companies.count()): + # item = self.list_companies.item(i) + # firma_data = item.data(Qt.ItemDataRole.UserRole) + # if firma_data["id"] == person["unternehmen_id"]: + # item.setSelected(True) + # self.gewaehltes_unternehmen = firma_data + # break + # self.list_companies.blockSignals(False) + + def _clear_company_selection(self) -> None: + self.list_companies.clearSelection() + self.STATE.un_id = None + self._sync_state_to_GUI(reset=False) + + def _clear_person_selection(self) -> None: + self.list_persons.clearSelection() + self.STATE.pers_id = None + self._sync_state_to_GUI(reset=False) + + def get_state(self) -> Page_Consulting_Linking_State: + return self.STATE + + class Page_Consulting_ConsultingSession(QWidget): """child of 'Page_Consulting_DynamicTable'""" @@ -5461,6 +5790,7 @@ class MainWindow(QMainWindow): self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) self.initrec_company.update_main_page.connect(self.update_grid) + self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person) self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index a4c0715..9f9f943 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -103,3 +103,15 @@ class CompanyProfileConsultationEntry: class CompanyProfileConsultations: pauschal: list[CompanyProfileConsultationEntry] individual: list[CompanyProfileConsultationEntry] + + +@dc.dataclass(slots=True) +class LinkingConsultationsEntryCompany: + un_id: RecId + display_name: str + + +@dc.dataclass(slots=True) +class LinkingConsultationsEntryPerson: + pers_id: RecId + display_name: str -- 2.34.1 From 9392f3fc77eb47f39b56d73296bbce0e319f4e2f Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 09:24:13 +0200 Subject: [PATCH 45/74] batch mode as default for `alembic` --- alembic/env.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/alembic/env.py b/alembic/env.py index 02f88a9..4e18418 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -46,6 +46,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + render_as_batch=True, ) with context.begin_transaction(): @@ -66,7 +67,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) with context.begin_transaction(): context.run_migrations() -- 2.34.1 From 65e3e10e13791a1c763f77a80ed11fc9525022b0 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 09:24:51 +0200 Subject: [PATCH 46/74] update database schema for linking of persons and companies --- ...f445_add_unique_constraint_for_company_.py | 33 +++++++++++++++++++ src/wce_crm/db.py | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py diff --git a/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py b/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py new file mode 100644 index 0000000..b88c4e6 --- /dev/null +++ b/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py @@ -0,0 +1,33 @@ +"""add unique constraint for company person linking table + +Revision ID: 03d4f000f445 +Revises: 85577ce9f2ab +Create Date: 2026-07-22 09:17:46.576581 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "03d4f000f445" +down_revision: Union[str, Sequence[str], None] = "85577ce9f2ab" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen") as batch_op: + batch_op.create_unique_constraint("uq_un_id_pers_id", ["un_id", "pers_id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen") as batch_op: + batch_op.drop_constraint("uq_un_id_pers_id") + # ### end Alembic commands ### diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 737ed5f..16350dc 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -7,7 +7,7 @@ from pathlib import Path import polars as pl import sqlalchemy as sql -from sqlalchemy import Column, String, Table, TypeDecorator, event +from sqlalchemy import Column, String, Table, TypeDecorator, UniqueConstraint, event from wce_crm import constants from wce_crm import types as t @@ -420,6 +420,7 @@ zuordnung_personen_unternehmen: sql.Table = Table( UTCDateTime, nullable=True, ), + UniqueConstraint("un_id", "pers_id", name="uq_un_id_pers_id"), ) -- 2.34.1 From 9a73f27518cc559f19efa5f30ad486d675189ff4 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 10:17:00 +0200 Subject: [PATCH 47/74] change database schema for linking table from unique constraint to partial index --- ...c0_change_unique_constraint_to_partial_.py | 47 +++ src/wce_crm/db.py | 334 +++++++++--------- 2 files changed, 217 insertions(+), 164 deletions(-) create mode 100644 alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py diff --git a/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py b/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py new file mode 100644 index 0000000..dbd9743 --- /dev/null +++ b/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py @@ -0,0 +1,47 @@ +"""change unique constraint to partial index for company person linking table + +Revision ID: 665259cdfdc0 +Revises: 03d4f000f445 +Create Date: 2026-07-22 10:14:43.889298 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "665259cdfdc0" +down_revision: Union[str, Sequence[str], None] = "03d4f000f445" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f("uq_un_id_pers_id"), type_="unique") + batch_op.create_index( + "uq_aktive_zuordnung", + ["un_id", "pers_id"], + unique=True, + sqlite_where=sa.text("gueltig_bis IS NULL"), + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_index( + "uq_aktive_zuordnung", sqlite_where=sa.text("gueltig_bis IS NULL") + ) + batch_op.create_unique_constraint( + batch_op.f("uq_un_id_pers_id"), ["un_id", "pers_id"] + ) + + # ### end Alembic commands ### diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 16350dc..af0734e 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -6,8 +6,8 @@ import re from pathlib import Path import polars as pl -import sqlalchemy as sql -from sqlalchemy import Column, String, Table, TypeDecorator, UniqueConstraint, event +import sqlalchemy as sa +from sqlalchemy import Column, Index, String, Table, TypeDecorator, event, sql from wce_crm import constants from wce_crm import types as t @@ -37,7 +37,7 @@ class SafeDateTime(TypeDecorator): class UTCDateTime(TypeDecorator): """Safely coerces naive datetimes from SQLite into timezone-aware UTC.""" - impl = sql.DateTime + impl = sa.DateTime cache_ok = True def process_bind_param(self, value, dialect): @@ -58,9 +58,9 @@ class UTCDateTime(TypeDecorator): return value -MD_CRM = sql.MetaData() -MD_MAIN = sql.MetaData() -ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") +MD_CRM = sa.MetaData() +MD_MAIN = sa.MetaData() +ENGINE = sa.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") @event.listens_for(ENGINE, "connect") @@ -71,33 +71,33 @@ def attach_second_db(dbapi_connection, connection_record): cursor.close() -ext_crm_master: sql.Table = Table( +ext_crm_master: Table = Table( "Master", MD_CRM, - Column("ma_id", sql.Integer, nullable=False, unique=True), - Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), - Column("ma_unternehmensname", sql.Text, nullable=True), - Column("ma_branche", sql.Text, nullable=True), - Column("ma_strasse", sql.Text, nullable=True), - Column("ma_hausnummer", sql.Text, nullable=True), - Column("ma_plz", sql.Text, nullable=True), - Column("ma_ort", sql.Text, nullable=True), - Column("ma_plz_postfach", sql.Text, nullable=True), - Column("ma_postfach", sql.Text, nullable=True), - Column("ma_website", sql.Text, nullable=True), - Column("ma_mail", sql.Text, nullable=True), - Column("ma_telefonnummer", sql.Text, nullable=True), - Column("ma_faxnummer", sql.Text, nullable=True), + Column("ma_id", sa.Integer, nullable=False, unique=True), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sa.Text, nullable=True), + Column("ma_branche", sa.Text, nullable=True), + Column("ma_strasse", sa.Text, nullable=True), + Column("ma_hausnummer", sa.Text, nullable=True), + Column("ma_plz", sa.Text, nullable=True), + Column("ma_ort", sa.Text, nullable=True), + Column("ma_plz_postfach", sa.Text, nullable=True), + Column("ma_postfach", sa.Text, nullable=True), + Column("ma_website", sa.Text, nullable=True), + Column("ma_mail", sa.Text, nullable=True), + Column("ma_telefonnummer", sa.Text, nullable=True), + Column("ma_faxnummer", sa.Text, nullable=True), Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), - Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), - Column("ma_sollprozess", sql.Text, nullable=True), - Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), - Column("ma_quelle_information", sql.Text, nullable=True), - Column("ma_bemerkung", sql.Text, nullable=True), - Column("ma_kontakt", sql.Boolean, nullable=True), - Column("ma_schlagworte", sql.Text, nullable=True), - Column("ma_archiviert", sql.Boolean, nullable=True, default=False), + Column("ma_aktualisierung_nutzer", sa.Text, nullable=True), + Column("ma_sollprozess", sa.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True), + Column("ma_quelle_information", sa.Text, nullable=True), + Column("ma_bemerkung", sa.Text, nullable=True), + Column("ma_kontakt", sa.Boolean, nullable=True), + Column("ma_schlagworte", sa.Text, nullable=True), + Column("ma_archiviert", sa.Boolean, nullable=True, default=False), ) @@ -141,26 +141,26 @@ def get_ext_crm_master( if not db_path.exists(): raise FileNotFoundError(f"Database not found under >{db_path}<") - engine = sql.create_engine(f"sqlite:///{db_path}") - stmt = sql.select(ext_crm_master) + engine = sa.create_engine(f"sqlite:///{db_path}") + stmt = sa.select(ext_crm_master) return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema) DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_CRM) -ext_crm_nutzer: sql.Table = Table( +ext_crm_nutzer: Table = Table( "Nutzer", MD_CRM, - Column("wce_id", sql.Integer, nullable=False, unique=True), - Column("wce_name", sql.Text, nullable=True), - Column("wce_vorname", sql.Text, nullable=True), - Column("wce_kuerzel", sql.Text, nullable=True), - Column("wce_passwort", sql.Text, nullable=True), - Column("wce_angelegt_am", sql.DateTime, nullable=True), - Column("wce_rolle", sql.Text, nullable=True), - Column("wce_angelegt_von", sql.Text, nullable=True), - Column("wce_aktiv", sql.Boolean, nullable=True), - Column("wce_letzter_login", sql.DateTime, nullable=True), + Column("wce_id", sa.Integer, nullable=False, unique=True), + Column("wce_name", sa.Text, nullable=True), + Column("wce_vorname", sa.Text, nullable=True), + Column("wce_kuerzel", sa.Text, nullable=True), + Column("wce_passwort", sa.Text, nullable=True), + Column("wce_angelegt_am", sa.DateTime, nullable=True), + Column("wce_rolle", sa.Text, nullable=True), + Column("wce_angelegt_von", sa.Text, nullable=True), + Column("wce_aktiv", sa.Boolean, nullable=True), + Column("wce_letzter_login", sa.DateTime, nullable=True), ) ext_crm_nutzer_schema: t.PolarsSchema = { @@ -176,31 +176,31 @@ ext_crm_nutzer_schema: t.PolarsSchema = { "wce_letzter_login": pl.Datetime, } -ext_crm_contact_person: sql.Table = Table( +ext_crm_contact_person: Table = Table( "Ansprechpartner", MD_CRM, - Column("an_id", sql.Integer, nullable=False, unique=True), - Column("ma_id", sql.ForeignKey("Master.ma_id")), - Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), - Column("st_id", sql.Integer, nullable=False), - Column("an_sachgebiet", sql.Text, nullable=True), - Column("an_anrede", sql.Text, nullable=True), - Column("an_titel", sql.Text, nullable=True), - Column("an_nachname", sql.Text, nullable=True), - Column("an_vorname", sql.Text, nullable=True), - Column("an_position", sql.Text, nullable=True), - Column("an_mail", sql.Text, nullable=True), - Column("an_festnetz", sql.Text, nullable=True), - Column("an_mobil", sql.Text, nullable=True), - Column("an_faxnummer", sql.Text, nullable=True), - Column("an_hauptansprechpartner", sql.Text, nullable=True), - Column("an_anrede_anschrift", sql.Text, nullable=True), - Column("an_bemerkung", sql.Text, nullable=True), + Column("an_id", sa.Integer, nullable=False, unique=True), + Column("ma_id", sa.ForeignKey("Master.ma_id")), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("st_id", sa.Integer, nullable=False), + Column("an_sachgebiet", sa.Text, nullable=True), + Column("an_anrede", sa.Text, nullable=True), + Column("an_titel", sa.Text, nullable=True), + Column("an_nachname", sa.Text, nullable=True), + Column("an_vorname", sa.Text, nullable=True), + Column("an_position", sa.Text, nullable=True), + Column("an_mail", sa.Text, nullable=True), + Column("an_festnetz", sa.Text, nullable=True), + Column("an_mobil", sa.Text, nullable=True), + Column("an_faxnummer", sa.Text, nullable=True), + Column("an_hauptansprechpartner", sa.Text, nullable=True), + Column("an_anrede_anschrift", sa.Text, nullable=True), + Column("an_bemerkung", sa.Text, nullable=True), Column("an_aktualisierung_datum", SafeDateTime, nullable=True), - Column("an_aktualisierung_nutzer", sql.Text, nullable=True), + Column("an_aktualisierung_nutzer", sa.Text, nullable=True), Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True), Column("an_ersteintrag_datum", SafeDateTime, nullable=True), - Column("an_archiviert", sql.Boolean, nullable=True, default=0), + Column("an_archiviert", sa.Boolean, nullable=True, default=0), ) @@ -242,8 +242,8 @@ def get_ext_crm_contact_person( if not db_path.exists(): raise FileNotFoundError(f"Database not found under >{db_path}<") - engine = sql.create_engine(f"sqlite:///{db_path}") - stmt = sql.select(ext_crm_contact_person) + engine = sa.create_engine(f"sqlite:///{db_path}") + stmt = sa.select(ext_crm_contact_person) df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema) df = df.with_columns( pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ") @@ -255,43 +255,43 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) -ext_crm_master_attach: sql.Table = Table( +ext_crm_master_attach: Table = Table( "Master", MD_CRM, - Column("ma_id", sql.Integer, nullable=False, unique=True), - Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), - Column("ma_unternehmensname", sql.Text, nullable=True), - Column("ma_branche", sql.Text, nullable=True), - Column("ma_strasse", sql.Text, nullable=True), - Column("ma_hausnummer", sql.Text, nullable=True), - Column("ma_plz", sql.Text, nullable=True), - Column("ma_ort", sql.Text, nullable=True), - Column("ma_plz_postfach", sql.Text, nullable=True), - Column("ma_postfach", sql.Text, nullable=True), - Column("ma_website", sql.Text, nullable=True), - Column("ma_mail", sql.Text, nullable=True), - Column("ma_telefonnummer", sql.Text, nullable=True), - Column("ma_faxnummer", sql.Text, nullable=True), + Column("ma_id", sa.Integer, nullable=False, unique=True), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sa.Text, nullable=True), + Column("ma_branche", sa.Text, nullable=True), + Column("ma_strasse", sa.Text, nullable=True), + Column("ma_hausnummer", sa.Text, nullable=True), + Column("ma_plz", sa.Text, nullable=True), + Column("ma_ort", sa.Text, nullable=True), + Column("ma_plz_postfach", sa.Text, nullable=True), + Column("ma_postfach", sa.Text, nullable=True), + Column("ma_website", sa.Text, nullable=True), + Column("ma_mail", sa.Text, nullable=True), + Column("ma_telefonnummer", sa.Text, nullable=True), + Column("ma_faxnummer", sa.Text, nullable=True), Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), - Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), - Column("ma_sollprozess", sql.Text, nullable=True), - Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), - Column("ma_quelle_information", sql.Text, nullable=True), - Column("ma_bemerkung", sql.Text, nullable=True), - Column("ma_kontakt", sql.Boolean, nullable=True), - Column("ma_schlagworte", sql.Text, nullable=True), - Column("ma_archiviert", sql.Boolean, nullable=True, default=False), + Column("ma_aktualisierung_nutzer", sa.Text, nullable=True), + Column("ma_sollprozess", sa.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True), + Column("ma_quelle_information", sa.Text, nullable=True), + Column("ma_bemerkung", sa.Text, nullable=True), + Column("ma_kontakt", sa.Boolean, nullable=True), + Column("ma_schlagworte", sa.Text, nullable=True), + Column("ma_archiviert", sa.Boolean, nullable=True, default=False), schema="kontaktliste", ) -grunderfassung_unternehmen: sql.Table = Table( +grunderfassung_unternehmen: Table = Table( "grunderfassung_unternehmen", MD_MAIN, Column( "un_id", - sql.Integer, + sa.Integer, primary_key=True, autoincrement=True, ), @@ -313,20 +313,20 @@ grunderfassung_unternehmen: sql.Table = Table( default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), - Column("Metadaten_nutzer", sql.String(20), nullable=True), - Column("Grunderfassung_fallnummer", sql.Text, nullable=True), - Column("Grunderfassung_notiz", sql.Text, nullable=True), - Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), - Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), - Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), + Column("Metadaten_nutzer", sa.String(20), nullable=True), + Column("Grunderfassung_fallnummer", sa.Text, nullable=True), + Column("Grunderfassung_notiz", sa.Text, nullable=True), + Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True, default=None), + Column("Partnersuche__person_suche", sa.Integer, nullable=True, default=None), + Column("Partnersuche__un_suche", sa.Integer, nullable=True, default=None), ) -grunderfassung_personen: sql.Table = Table( +grunderfassung_personen: Table = Table( "grunderfassung_personen", MD_MAIN, Column( "pers_id", - sql.Integer, + sa.Integer, primary_key=True, autoincrement=True, ), @@ -348,67 +348,67 @@ grunderfassung_personen: sql.Table = Table( default=lambda: datetime.datetime.now(datetime.UTC), onupdate=lambda: datetime.datetime.now(datetime.UTC), ), - Column("Metadaten_nutzer", sql.String(20), nullable=True), - Column("Metadaten_wiedereintrittsdatum", sql.Date, nullable=True, default=None), - Column("Arbeitserfahrung", sql.Text, nullable=True), - Column("Grunderfassung_fallnummer", sql.Text, nullable=True), - Column("Grunderfassung_notiz", sql.Text, nullable=True), - Column("HoehereBildung", sql.Text, nullable=True), - Column("Kontaktperson__KP_adresse", sql.Text, nullable=True), - Column("Kontaktperson__KP_anrede_anschrift", sql.Text, nullable=True), - Column("Kontaktperson__KP_email", sql.Text, nullable=True), - Column("Kontaktperson__KP_festnetznummer", sql.Text, nullable=True), - Column("Kontaktperson__KP_funktion_beziehung", sql.Text, nullable=True), - Column("Kontaktperson__KP_mobilfunknummer", sql.Text, nullable=True), - Column("Kontaktperson__KP_name", sql.Text, nullable=True), + Column("Metadaten_nutzer", sa.String(20), nullable=True), + Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), + Column("Arbeitserfahrung", sa.Text, nullable=True), + Column("Grunderfassung_fallnummer", sa.Text, nullable=True), + Column("Grunderfassung_notiz", sa.Text, nullable=True), + Column("HoehereBildung", sa.Text, nullable=True), + Column("Kontaktperson__KP_adresse", sa.Text, nullable=True), + Column("Kontaktperson__KP_anrede_anschrift", sa.Text, nullable=True), + Column("Kontaktperson__KP_email", sa.Text, nullable=True), + Column("Kontaktperson__KP_festnetznummer", sa.Text, nullable=True), + Column("Kontaktperson__KP_funktion_beziehung", sa.Text, nullable=True), + Column("Kontaktperson__KP_mobilfunknummer", sa.Text, nullable=True), + Column("Kontaktperson__KP_name", sa.Text, nullable=True), Column( - "Kontaktperson__KP_name_partner", sql.Text, nullable=True + "Kontaktperson__KP_name_partner", sa.Text, nullable=True ), # TODO: check if needed when set by trigger - Column("Kontaktperson__KP_titel", sql.Text, nullable=True), - Column("Kontaktperson__KP_vorname", sql.Text, nullable=True), - Column("Projektrelevanz__relevanz", sql.Text, nullable=True), - Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True), - Column("Schulbildung", sql.Text, nullable=True), - Column("Sprachkenntnisse", sql.Text, nullable=True), - Column("Stammdaten__PLZ", sql.Text, nullable=True), - Column("Stammdaten__anrede_anschrift", sql.Text, nullable=True), - Column("Stammdaten__anzahl_kinder__alter", sql.Text, nullable=True), - Column("Stammdaten__anzahl_kinder__anzahl", sql.Integer, nullable=True), - Column("Stammdaten__aufenthaltsort", sql.Text, nullable=True), - Column("Stammdaten__bundesland", sql.Text, nullable=True), - Column("Stammdaten__land", sql.Text, nullable=True), - Column("Stammdaten__email", sql.Text, nullable=True), - Column("Stammdaten__familienstand", sql.Text, nullable=True), - Column("Stammdaten__festnetznummer", sql.Text, nullable=True), - Column("Stammdaten__geburtsdatum", sql.Date, nullable=True), - Column("Stammdaten__hausnummer", sql.Text, nullable=True), - Column("Stammdaten__herkunftsland", sql.Text, nullable=True), - Column("Stammdaten__mobilfunknummer", sql.Text, nullable=True), - Column("Stammdaten__name", sql.Text, nullable=True), - Column("Stammdaten__ort", sql.Text, nullable=True), - Column("Stammdaten__rueckkehrer", sql.Boolean, nullable=True), - Column("Stammdaten__staatsangehoerigkeit", sql.Text, nullable=True), - Column("Stammdaten__strasse", sql.Text, nullable=True), - Column("Stammdaten__titel", sql.Text, nullable=True), - Column("Stammdaten__vorname", sql.Text, nullable=True), - Column("WeitereInfos__WI_arbeitsstatus", sql.Text, nullable=True), - Column("WeitereInfos__WI_aufenthaltstitel", sql.Text, nullable=True), - Column("WeitereInfos__WI_deutsch_sprache", sql.Text, nullable=True), - Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True), - Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), + Column("Kontaktperson__KP_titel", sa.Text, nullable=True), + Column("Kontaktperson__KP_vorname", sa.Text, nullable=True), + Column("Projektrelevanz__relevanz", sa.Text, nullable=True), + Column("Projektrelevanz__foerderperiode", sa.Text, nullable=True), + Column("Schulbildung", sa.Text, nullable=True), + Column("Sprachkenntnisse", sa.Text, nullable=True), + Column("Stammdaten__PLZ", sa.Text, nullable=True), + Column("Stammdaten__anrede_anschrift", sa.Text, nullable=True), + Column("Stammdaten__anzahl_kinder__alter", sa.Text, nullable=True), + Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer, nullable=True), + Column("Stammdaten__aufenthaltsort", sa.Text, nullable=True), + Column("Stammdaten__bundesland", sa.Text, nullable=True), + Column("Stammdaten__land", sa.Text, nullable=True), + Column("Stammdaten__email", sa.Text, nullable=True), + Column("Stammdaten__familienstand", sa.Text, nullable=True), + Column("Stammdaten__festnetznummer", sa.Text, nullable=True), + Column("Stammdaten__geburtsdatum", sa.Date, nullable=True), + Column("Stammdaten__hausnummer", sa.Text, nullable=True), + Column("Stammdaten__herkunftsland", sa.Text, nullable=True), + Column("Stammdaten__mobilfunknummer", sa.Text, nullable=True), + Column("Stammdaten__name", sa.Text, nullable=True), + Column("Stammdaten__ort", sa.Text, nullable=True), + Column("Stammdaten__rueckkehrer", sa.Boolean, nullable=True), + Column("Stammdaten__staatsangehoerigkeit", sa.Text, nullable=True), + Column("Stammdaten__strasse", sa.Text, nullable=True), + Column("Stammdaten__titel", sa.Text, nullable=True), + Column("Stammdaten__vorname", sa.Text, nullable=True), + Column("WeitereInfos__WI_arbeitsstatus", sa.Text, nullable=True), + Column("WeitereInfos__WI_aufenthaltstitel", sa.Text, nullable=True), + Column("WeitereInfos__WI_deutsch_sprache", sa.Text, nullable=True), + Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date, nullable=True), + Column("WeitereInfos__WI_meldung_institution", sa.Text, nullable=True), ) -zuordnung_personen_unternehmen: sql.Table = Table( +zuordnung_personen_unternehmen: Table = Table( "zuordnung_personen_unternehmen", MD_MAIN, Column( "id", - sql.Integer, + sa.Integer, primary_key=True, autoincrement=True, ), - Column("un_id", sql.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=False), - Column("pers_id", sql.ForeignKey("grunderfassung_personen.pers_id"), nullable=False), + Column("un_id", sa.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=False), + Column("pers_id", sa.ForeignKey("grunderfassung_personen.pers_id"), nullable=False), Column( "gueltig_ab", UTCDateTime, @@ -420,23 +420,29 @@ zuordnung_personen_unternehmen: sql.Table = Table( UTCDateTime, nullable=True, ), - UniqueConstraint("un_id", "pers_id", name="uq_un_id_pers_id"), + Index( + "uq_aktive_zuordnung", + "un_id", + "pers_id", + unique=True, + sqlite_where=sql.text("gueltig_bis IS NULL"), + ), ) -beratung_vorgang: sql.Table = Table( +beratung_vorgang: Table = Table( "beratung_vorgang", MD_MAIN, Column( "vorgang_id", - sql.Integer, + sa.Integer, primary_key=True, autoincrement=True, ), - Column("un_id", sql.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=True), - Column("pers_id", sql.ForeignKey("grunderfassung_personen.pers_id"), nullable=True), - Column("titel", sql.Text, nullable=False), - Column("beratungs_typ", sql.String(20), nullable=False), + Column("un_id", sa.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=True), + Column("pers_id", sa.ForeignKey("grunderfassung_personen.pers_id"), nullable=True), + Column("titel", sa.Text, nullable=False), + Column("beratungs_typ", sa.String(20), nullable=False), Column( "erstellt", UTCDateTime, @@ -458,29 +464,29 @@ beratung_vorgang: sql.Table = Table( ) -beratung_einzelberatung: sql.Table = Table( +beratung_einzelberatung: Table = Table( "beratung_einzelberatung", MD_MAIN, Column( "beratung_id", - sql.Integer, + sa.Integer, primary_key=True, autoincrement=True, ), - Column("vorgang_id", sql.ForeignKey("beratung_vorgang.vorgang_id"), nullable=False), - Column("nutzer_id", sql.Integer, nullable=False), - Column("nutzer_name", sql.String(20), nullable=False), + Column("vorgang_id", sa.ForeignKey("beratung_vorgang.vorgang_id"), nullable=False), + Column("nutzer_id", sa.Integer, nullable=False), + Column("nutzer_name", sa.String(20), nullable=False), Column( "zeitstempel", UTCDateTime, nullable=False, default=lambda: datetime.datetime.now(datetime.UTC), ), - Column("ansprechpartner", sql.Text, nullable=False), - Column("kommunikationsweg", sql.Text, nullable=False), - Column("thema_crm_matrix", sql.Text, nullable=True), - Column("anmerkungen", sql.Text, nullable=True), - Column("rueckmeldung", sql.Text, nullable=True), + Column("ansprechpartner", sa.Text, nullable=False), + Column("kommunikationsweg", sa.Text, nullable=False), + Column("thema_crm_matrix", sa.Text, nullable=True), + Column("anmerkungen", sa.Text, nullable=True), + Column("rueckmeldung", sa.Text, nullable=True), Column( "erstellt", UTCDateTime, -- 2.34.1 From 1cab78b2b417b45db4c55127c53f40539457a9ae Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 10:20:10 +0200 Subject: [PATCH 48/74] prepare linking of persons and companies --- src/wce_crm/backend/backend.py | 49 ++++++++++++++++++++++++++++------ src/wce_crm/data_models.py | 1 + src/wce_crm/gui.py | 25 ++++++++++++----- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index d23b884..361d149 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -6,8 +6,9 @@ from pprint import pformat from typing import TYPE_CHECKING, Any, cast import polars as pl -import sqlalchemy as sql +import sqlalchemy as sa from dopt_basics.result_pattern import wrap_result +from sqlalchemy.dialects.sqlite import insert as sqlite_insert from wce_crm import db from wce_crm.constants import TIMEZONE_CEST @@ -479,7 +480,7 @@ def page_consulting_to_db( "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) ) stmt = db.beratung_einzelberatung.update().where( - db.beratung_einzelberatung.c.beratung_id == sql.bindparam("b_beratung_id") + db.beratung_einzelberatung.c.beratung_id == sa.bindparam("b_beratung_id") ) conn.execute(stmt, rows_for_db_update) @@ -599,7 +600,7 @@ def page_consulting_linking_companies( un_id: RecId | None, ) -> list[LinkingConsultationsEntryCompany]: with db.ENGINE.connect() as conn: - q_relevant_companies = sql.select( + q_relevant_companies = sa.select( db.grunderfassung_unternehmen.c.un_id, db.grunderfassung_unternehmen.c.Partnersuche__un_suche, ).where( @@ -613,7 +614,7 @@ def page_consulting_linking_companies( q_relevant_companies = q_relevant_companies.subquery("relevant_companies") - stmt = sql.select( + stmt = sa.select( q_relevant_companies.c.un_id, db.ext_crm_master_attach.c.ma_unternehmensname, ).select_from( @@ -650,7 +651,7 @@ def page_consulting_linking_persons( # if un_id None: list all persons # else: list just the persons associated with the company - person_select = sql.select( + person_select = sa.select( db.grunderfassung_personen.c.pers_id, db.grunderfassung_personen.c.Stammdaten__name, db.grunderfassung_personen.c.Stammdaten__vorname, @@ -698,7 +699,7 @@ def companyprofile_page_get_consultations( un_id: RecId, ) -> CompanyProfileConsultations: logger.debug("[Call backend] _companyprofile_page_get_consultations") - stmt = sql.select( + stmt = sa.select( db.beratung_vorgang.c.vorgang_id, db.beratung_vorgang.c.aktualisiert, db.beratung_vorgang.c.titel, @@ -746,10 +747,42 @@ def companyprofile_page_get_consultations( ) +# // initial recording interaction +@wrap_result(10) +def initrec_link_person_company( + un_id: RecId, + pers_id: RecId, + unlink: bool, +) -> None: + if unlink: + deleted_ts = datetime.datetime.now(tz=datetime.UTC) + stmt = ( + sa.update(db.zuordnung_personen_unternehmen) + .where( + db.zuordnung_personen_unternehmen.c.un_id == un_id, + db.zuordnung_personen_unternehmen.c.pers_id == pers_id, + db.zuordnung_personen_unternehmen.c.gueltig_bis.is_(None), + ) + .values(gueltig_bis=deleted_ts) + ) + else: + stmt = ( + sqlite_insert(db.zuordnung_personen_unternehmen) + .values(un_id=un_id, pers_id=pers_id) + .on_conflict_do_nothing( + index_elements=["un_id", "pers_id"], + index_where=sa.text("gueltig_bis IS NULL"), + ) + ) + + with db.ENGINE.begin() as conn: + conn.execute(stmt) + + # // main page interaction def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") - stmt = sql.select( + stmt = sa.select( db.grunderfassung_unternehmen.c.un_id, db.grunderfassung_unternehmen.c.Partnersuche__un_suche, db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, @@ -785,7 +818,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: def _main_page_get_person_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") - stmt = sql.select( + stmt = sa.select( db.grunderfassung_personen.c.pers_id, db.grunderfassung_personen.c.Stammdaten__vorname, db.grunderfassung_personen.c.Stammdaten__name, diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index b444340..10e19ee 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -248,6 +248,7 @@ class AutoForm_State(PageState[Module]): class Page_InitRecPerson_State(PageState[Module]): session: Session pers_id: RecId | None = None + un_id: RecId | None = None locked: bool diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index f0fa05e..f607776 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3149,6 +3149,7 @@ class Page_InitRecCompany(QWidget): req_state = Page_InitRecPerson_State( session=self.STATE.session, pers_id=None, + un_id=self.STATE.un_id, locked=False, ) logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) @@ -3365,6 +3366,7 @@ class Page_InitRecPerson(QWidget): def _auto_form_updated(self) -> None: self._sync_GUI_to_state() self._sync_state_to_GUI("banners") + # TODO saving logic to connect company and person if needed def reset_form(self) -> None: self.auto_form.reset_form() @@ -4318,15 +4320,24 @@ class Page_Consulting(QWidget): # .exec() öffnet das Fenster modal (blockiert das Hauptfenster) if dialog.exec() == QDialog.DialogCode.Accepted: # Hier hast du am Ende beide (oder nur eine) Entitäten auswertbar vorliegen: - firma = dialog.gewaehltes_unternehmen - person = dialog.gewaehlte_person + state = dialog.get_state() + un_id = state.un_id + pers_id = state.pers_id - comp_name = firma["name"] if firma else "Keine" - person_name = person["name"] if person else "Keine" + # TODO add infos to DB table "Beratung_Vorgang" + assert self.STATE.vorgang_id is not None, ( + "cannot link consulting process which is not already saved" + ) + + # firma = dialog.gewaehltes_unternehmen + # person = dialog.gewaehlte_person + + # comp_name = firma["name"] if firma else "Keine" + # person_name = person["name"] if person else "Keine" logger_page_consulting.info( - "[Consulting Page] Firme gewählt: %s, Person gewählt: %s", - comp_name, - person_name, + "[Consulting Page] Company selected (ID): %s, Person selected (ID): %s", + un_id, + pers_id, ) # 1. TODO: Datenbank-Update ausführen (Fremdschlüssel setzen) -- 2.34.1 From c1843702f88b03b66e167cbfea67c9a055135490 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 10:45:18 +0200 Subject: [PATCH 49/74] linking of consultation processes to entities in the database (not tested yet) --- src/wce_crm/backend/backend.py | 19 ++++++++++++++++++- src/wce_crm/gui.py | 30 ++++++++++++++---------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 361d149..bed6f23 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -695,6 +695,23 @@ def page_consulting_linking_persons( return linking_entries +# TODO wrap result +def page_consulting_eastablish_link( + vorgang_id: ConsId, + un_id: RecId | None, + pers_id: RecId | None, +) -> None: + + stmt = ( + sa.update(db.beratung_vorgang) + .where(db.beratung_vorgang.c.vorgang_id == vorgang_id) + .values(un_id=un_id, pers_id=pers_id) + ) + + with db.ENGINE.begin() as conn: + conn.execute(stmt) + + def companyprofile_page_get_consultations( un_id: RecId, ) -> CompanyProfileConsultations: @@ -748,7 +765,7 @@ def companyprofile_page_get_consultations( # // initial recording interaction -@wrap_result(10) +# TODO wrap result def initrec_link_person_company( un_id: RecId, pers_id: RecId, diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index f607776..873e506 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -4317,34 +4317,32 @@ class Page_Consulting(QWidget): ) dialog = Page_Consulting_LinkingDialog(dialog_state, parent=self) - # .exec() öffnet das Fenster modal (blockiert das Hauptfenster) + # .exec() blocks main window if dialog.exec() == QDialog.DialogCode.Accepted: # Hier hast du am Ende beide (oder nur eine) Entitäten auswertbar vorliegen: - state = dialog.get_state() - un_id = state.un_id - pers_id = state.pers_id + dialog_state = dialog.get_state() + self.STATE.un_id = dialog_state.un_id + self.STATE.pers_id = dialog_state.pers_id - # TODO add infos to DB table "Beratung_Vorgang" assert self.STATE.vorgang_id is not None, ( "cannot link consulting process which is not already saved" ) - - # firma = dialog.gewaehltes_unternehmen - # person = dialog.gewaehlte_person - - # comp_name = firma["name"] if firma else "Keine" - # person_name = person["name"] if person else "Keine" + # TODO error handling + backend.page_consulting_eastablish_link( + vorgang_id=self.STATE.vorgang_id, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + ) logger_page_consulting.info( "[Consulting Page] Company selected (ID): %s, Person selected (ID): %s", - un_id, - pers_id, + self.STATE.un_id, + self.STATE.pers_id, ) - - # 1. TODO: Datenbank-Update ausführen (Fremdschlüssel setzen) - # 2. Banner ausblenden: self.banner.hide() else: logger_page_consulting.info("[Consulting Page] Verknüpfung abgebrochen.") + self._sync_state_to_GUI("banners") + def _clear_info_banners(self) -> None: while self.info_banners: banner = self.info_banners[0] -- 2.34.1 From ffe38a4ee726ac206aa73e0ad609a23509e70a96 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 11:19:07 +0200 Subject: [PATCH 50/74] basic cleansing and hints --- src/wce_crm/backend/backend.py | 216 +++++++++++++++++---------------- src/wce_crm/data_models.py | 62 ++++------ src/wce_crm/gui.py | 8 +- src/wce_crm/types.py | 6 + 4 files changed, 145 insertions(+), 147 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index bed6f23..b4fb30e 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -15,7 +15,7 @@ from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, - Initrec, + InitRec, ) from wce_crm.logging import logger_back as logger from wce_crm.types import ( @@ -121,38 +121,39 @@ def initrec_comp_contact_person_search_get_info( # // internals -def initrec_company_insert_initial_recording( - data: dict[str, Any], -) -> RecId: - logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung_unternehmen.insert() - with db.ENGINE.begin() as conn: - ret = conn.execute(stmt, data) +# TODO check removal +# def initrec_company_insert_initial_recording( +# data: dict[str, Any], +# ) -> RecId: +# logger.debug("[Call backend] insert_initial_recording") +# stmt = db.grunderfassung_unternehmen.insert() +# with db.ENGINE.begin() as conn: +# ret = conn.execute(stmt, data) - if ret.rowcount == 0: - raise IOError("Entry was not inserted correctly") +# if ret.rowcount == 0: +# raise IOError("Entry was not inserted correctly") - prim_keys = ret.inserted_primary_key - assert prim_keys +# prim_keys = ret.inserted_primary_key +# assert prim_keys - return prim_keys[0] +# return prim_keys[0] -def initrec_company_update_initial_recording( - id_: RecId, - data: dict[str, Any], -) -> None: - logger.debug("[Call backend] update_initial_recording") - stmt = db.grunderfassung_unternehmen.update().where( - db.grunderfassung_unternehmen.c.un_id == id_ - ) - with db.ENGINE.begin() as conn: - conn.execute(stmt, data) +# def initrec_company_update_initial_recording( +# id_: RecId, +# data: dict[str, Any], +# ) -> None: +# logger.debug("[Call backend] update_initial_recording") +# stmt = db.grunderfassung_unternehmen.update().where( +# db.grunderfassung_unternehmen.c.un_id == id_ +# ) +# with db.ENGINE.begin() as conn: +# conn.execute(stmt, data) def initrec_company_to_db( - auto_form_data: Initrec, -) -> Initrec: + auto_form_data: InitRec, +) -> InitRec: logger.debug("[AutoForm -- backend] Call database saving routine...") dump_data = copy.deepcopy(auto_form_data.db_data) @@ -212,7 +213,7 @@ def initrec_company_to_db( def initrec_company_from_db( id_: RecId, -) -> Initrec: +) -> InitRec: logger.debug("[AutoForm -- backend] Call database loading routine...") stmt = db.grunderfassung_unternehmen.select().where( db.grunderfassung_unternehmen.c.un_id == id_ @@ -227,7 +228,7 @@ def initrec_company_from_db( geloescht = data_from_db["geloescht"] del data_from_db["geloescht"] - return Initrec( + return InitRec( rec_id=id_, geloescht=geloescht, db_data=data_from_db, @@ -235,8 +236,8 @@ def initrec_company_from_db( def initrec_person_to_db( - auto_form_data: Initrec, -) -> Initrec: + auto_form_data: InitRec, +) -> InitRec: logger.debug("[AutoForm -- backend] Call database saving routine...") dump_data = copy.deepcopy(auto_form_data.db_data) @@ -296,7 +297,7 @@ def initrec_person_to_db( def initrec_person_from_db( id_: RecId, -) -> Initrec: +) -> InitRec: logger.debug("[AutoForm -- backend] Call database loading routine...") stmt = db.grunderfassung_personen.select().where( db.grunderfassung_personen.c.pers_id == id_ @@ -311,112 +312,113 @@ def initrec_person_from_db( geloescht = data_from_db["geloescht"] del data_from_db["geloescht"] - return Initrec( + return InitRec( rec_id=id_, geloescht=geloescht, db_data=data_from_db, ) -def initrec_company_get_initial_recording( - id_: RecId, -) -> dict[str, Any]: - logger.debug("[Call backend] get_initial_recording") - stmt = db.grunderfassung_unternehmen.select().where( - db.grunderfassung_unternehmen.c.un_id == id_ - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) +# TODO check removal +# def initrec_company_get_initial_recording( +# id_: RecId, +# ) -> dict[str, Any]: +# logger.debug("[Call backend] get_initial_recording") +# stmt = db.grunderfassung_unternehmen.select().where( +# db.grunderfassung_unternehmen.c.un_id == id_ +# ) +# with db.ENGINE.connect() as conn: +# ret = conn.execute(stmt) - results = ret.mappings().all() - if not results: - raise KeyError(f"Database ID {id_} not found") +# results = ret.mappings().all() +# if not results: +# raise KeyError(f"Database ID {id_} not found") - assert len(results) == 1, "more than one company initial recording obtained" +# assert len(results) == 1, "more than one company initial recording obtained" - row = results[0] - assert row, "row was not obtained" +# row = results[0] +# assert row, "row was not obtained" - return dict(row) +# return dict(row) -def initrec_company_delete_initial_recording( - id_: RecId, -) -> None: - logger.debug("[Call backend] delete_initial_recording") - stmt = db.grunderfassung_unternehmen.delete().where( - db.grunderfassung_unternehmen.c.un_id == id_ - ) - with db.ENGINE.begin() as conn: - ret = conn.execute(stmt) +# def initrec_company_delete_initial_recording( +# id_: RecId, +# ) -> None: +# logger.debug("[Call backend] delete_initial_recording") +# stmt = db.grunderfassung_unternehmen.delete().where( +# db.grunderfassung_unternehmen.c.un_id == id_ +# ) +# with db.ENGINE.begin() as conn: +# ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found for deletion") +# if ret.rowcount == 0: +# raise KeyError(f"Database ID {id_} not found for deletion") -def initrec_person_insert_initial_recording( - data: dict[str, Any], -) -> RecId: - logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung_personen.insert() - with db.ENGINE.begin() as conn: - ret = conn.execute(stmt, data) +# def initrec_person_insert_initial_recording( +# data: dict[str, Any], +# ) -> RecId: +# logger.debug("[Call backend] insert_initial_recording") +# stmt = db.grunderfassung_personen.insert() +# with db.ENGINE.begin() as conn: +# ret = conn.execute(stmt, data) - if ret.rowcount == 0: - raise IOError("Entry was not inserted correctly") +# if ret.rowcount == 0: +# raise IOError("Entry was not inserted correctly") - prim_keys = ret.inserted_primary_key - assert prim_keys +# prim_keys = ret.inserted_primary_key +# assert prim_keys - return prim_keys[0] +# return prim_keys[0] -def initrec_person_update_initial_recording( - id_: RecId, - data: dict[str, Any], -) -> None: - logger.debug("[Call backend] update_initial_recording") - stmt = db.grunderfassung_personen.update().where( - db.grunderfassung_personen.c.pers_id == id_ - ) - with db.ENGINE.begin() as conn: - conn.execute(stmt, data) +# def initrec_person_update_initial_recording( +# id_: RecId, +# data: dict[str, Any], +# ) -> None: +# logger.debug("[Call backend] update_initial_recording") +# stmt = db.grunderfassung_personen.update().where( +# db.grunderfassung_personen.c.pers_id == id_ +# ) +# with db.ENGINE.begin() as conn: +# conn.execute(stmt, data) -def initrec_person_get_initial_recording( - id_: RecId, -) -> dict[str, Any]: - logger.debug("[Call backend] get_initial_recording person") - stmt = db.grunderfassung_personen.select().where( - db.grunderfassung_personen.c.pers_id == id_ - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) +# def initrec_person_get_initial_recording( +# id_: RecId, +# ) -> dict[str, Any]: +# logger.debug("[Call backend] get_initial_recording person") +# stmt = db.grunderfassung_personen.select().where( +# db.grunderfassung_personen.c.pers_id == id_ +# ) +# with db.ENGINE.connect() as conn: +# ret = conn.execute(stmt) - results = ret.mappings().all() - if not results: - raise KeyError(f"Database ID {id_} not found") +# results = ret.mappings().all() +# if not results: +# raise KeyError(f"Database ID {id_} not found") - assert len(results) == 1, "more than one person initial recording obtained" +# assert len(results) == 1, "more than one person initial recording obtained" - row = results[0] - assert row, "row was not obtained" +# row = results[0] +# assert row, "row was not obtained" - return dict(row) +# return dict(row) -def initrec_person_delete_initial_recording( - id_: RecId, -) -> None: - logger.debug("[Call backend] delete_initial_recording") - stmt = db.grunderfassung_personen.delete().where( - db.grunderfassung_personen.c.pers_id == id_ - ) - with db.ENGINE.begin() as conn: - ret = conn.execute(stmt) +# def initrec_person_delete_initial_recording( +# id_: RecId, +# ) -> None: +# logger.debug("[Call backend] delete_initial_recording") +# stmt = db.grunderfassung_personen.delete().where( +# db.grunderfassung_personen.c.pers_id == id_ +# ) +# with db.ENGINE.begin() as conn: +# ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found for deletion") +# if ret.rowcount == 0: +# raise KeyError(f"Database ID {id_} not found for deletion") @wrap_result(10) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 10e19ee..a008ccf 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -148,47 +148,48 @@ class Module(WrapperModule, Protocol): def save_data(self) -> None: ... -class AutoFormInsert(Protocol): - def __call__( - self, - data: dict[str, Any], - ) -> RecId: ... +# TODO check removal +# class AutoFormInsert(Protocol): +# def __call__( +# self, +# data: dict[str, Any], +# ) -> RecId: ... -class AutoFormUpdate(Protocol): - def __call__( - self, - id_: int, - data: dict[str, Any], - ) -> None: ... +# class AutoFormUpdate(Protocol): +# def __call__( +# self, +# id_: int, +# data: dict[str, Any], +# ) -> None: ... -class AutoFormGet(Protocol): - def __call__( - self, - id_: int, - ) -> dict[str, Any]: ... +# class AutoFormGet(Protocol): +# def __call__( +# self, +# id_: int, +# ) -> dict[str, Any]: ... -class AutoFormDelete(Protocol): - def __call__( - self, - id_: int, - ) -> None: ... +# class AutoFormDelete(Protocol): +# def __call__( +# self, +# id_: int, +# ) -> None: ... class AutoFormToDb(Protocol): def __call__( self, - auto_form_data: Initrec, - ) -> Initrec: ... + auto_form_data: InitRec, + ) -> InitRec: ... class AutoFormFromDb(Protocol): def __call__( self, id_: int, - ) -> Initrec: ... + ) -> InitRec: ... @dc.dataclass(slots=True) @@ -225,10 +226,6 @@ class AutoFormConfig: model: type[FlatBaseModel] to_db: AutoFormToDb from_db: AutoFormFromDb - # data_insert: AutoFormInsert - # data_update: AutoFormUpdate - # data_get: AutoFormGet - # data_delete: AutoFormDelete form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = True @@ -421,14 +418,7 @@ class FlatBaseModel(BaseModel): # ** InitRec -# class Initrec_FromDb(BaseModel): -# rec_id: RecId -# geloescht: AwareDatetime | None -# Metadaten_aktualisierung: AwareDatetime -# form_data: dict[str, Any] - - -class Initrec(BaseModel): +class InitRec(BaseModel): rec_id: RecId | None geloescht: AwareDatetime | None = None db_data: dict[str, Any] diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 873e506..2031744 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -90,7 +90,7 @@ from wce_crm.data_models import ( Beratungsgespraech_Vorgang, Grunderfassung_Personen, Grunderfassung_Unternehmen, - Initrec, + InitRec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, @@ -1678,7 +1678,7 @@ class AutoForm(QWidget): def _load_from_pydantic( self, - data: Initrec, + data: InitRec, ) -> None: self.STATE.rec_id = data.rec_id self.STATE.form_data = None @@ -1700,7 +1700,7 @@ class AutoForm(QWidget): ) deleted_datetime = datetime.datetime.now(datetime.UTC) assert self.STATE.form_data, "cannot delete entry with uninitialised form data" - to_db = Initrec( + to_db = InitRec( rec_id=self.STATE.rec_id, geloescht=deleted_datetime, db_data={}, @@ -1758,7 +1758,7 @@ class AutoForm(QWidget): deleted_datetime = datetime.datetime.now(datetime.UTC) try: # TODO change to result pattern - to_db = Initrec( + to_db = InitRec( rec_id=self.STATE.rec_id, geloescht=deleted_datetime, db_data=db_data, diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 9f9f943..dcf3e05 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -28,6 +28,12 @@ class ConsultingType(enum.StrEnum): INDIVIDUAL = enum.auto() +@dc.dataclass(slots=True) +class EntityIds: + un_id: RecId | None = None + pers_id: RecId | None = None + + # // database interaction data structures # ** externals class CompanyInfo(TypedDict): -- 2.34.1 From 6e04a7485a171bed2b6ce9c5baf9dcf6664fe846 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 15:26:15 +0200 Subject: [PATCH 51/74] added entity ID container --- src/wce_crm/backend/backend.py | 82 ++++++------ src/wce_crm/data_models.py | 107 +++++++++------- src/wce_crm/gui.py | 228 ++++++++++++++++++++------------- src/wce_crm/types.py | 52 +++++++- 4 files changed, 293 insertions(+), 176 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index b4fb30e..f7e5ee0 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -24,7 +24,8 @@ from wce_crm.types import ( CompanyProfileConsultations, ConsultingType, ContactPersonInfo, - InitRecType, + EntityIds, + EntityType, LinkingConsultationsEntryCompany, LinkingConsultationsEntryPerson, MainPageEntry, @@ -160,7 +161,7 @@ def initrec_company_to_db( dump_data["geloescht"] = auto_form_data.geloescht with db.ENGINE.begin() as conn: - if auto_form_data.rec_id is None: + if auto_form_data.ids.un_id is None: logger.debug("[AutoForm -- backend] Insert...") stmt = db.grunderfassung_unternehmen.insert().returning( @@ -169,25 +170,23 @@ def initrec_company_to_db( db.grunderfassung_unternehmen.c.geloescht, ) ret = conn.execute(stmt, dump_data) - if ret.rowcount == 0: - raise IOError("Entry was not inserted correctly") from_db = ret.mappings().fetchall() assert len(from_db) == 1, "expected excactly one returned row" data_from_db = from_db[0] - auto_form_data.rec_id = data_from_db["un_id"] + auto_form_data.ids.un_id = data_from_db["un_id"] auto_form_data.geloescht = data_from_db["geloescht"] auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] - logger.debug("[AutoForm -- backend] Inserted InitRec successfully") + logger.debug("[AutoForm -- backend] Inserted InitRec Company successfully") else: logger.debug("[AutoForm -- backend] Update...") stmt = ( db.grunderfassung_unternehmen.update() - .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.rec_id) + .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.ids.un_id) .returning( db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, db.grunderfassung_unternehmen.c.geloescht, @@ -204,8 +203,8 @@ def initrec_company_to_db( ] logger.debug( - "[AutoForm -- backend] Updated InitRec with ID %d successfully", - auto_form_data.rec_id, + "[AutoForm -- backend] Updated InitRec Company with ID %d successfully", + auto_form_data.ids.un_id, ) return auto_form_data @@ -229,7 +228,8 @@ def initrec_company_from_db( del data_from_db["geloescht"] return InitRec( - rec_id=id_, + ids=EntityIds(un_id=id_), + # rec_id=id_, # TODO remove geloescht=geloescht, db_data=data_from_db, ) @@ -244,34 +244,39 @@ def initrec_person_to_db( dump_data["geloescht"] = auto_form_data.geloescht with db.ENGINE.begin() as conn: - if auto_form_data.rec_id is None: + if auto_form_data.ids.pers_id is None: logger.debug("[AutoForm -- backend] Insert...") - stmt = db.grunderfassung_personen.insert().returning( + stmt = sa.insert(db.grunderfassung_personen).returning( db.grunderfassung_personen.c.pers_id, db.grunderfassung_personen.c.Metadaten_aktualisierung, db.grunderfassung_personen.c.geloescht, ) + stmt_compiled = str(stmt.compile(db.ENGINE)) + logger.debug( + "[AutoForm -- backend] Data to insert:\nStatement: %s\n%s", + stmt_compiled, + pformat(dump_data), + ) + ret = conn.execute(stmt, dump_data) - if ret.rowcount == 0: - raise IOError("Entry was not inserted correctly") from_db = ret.mappings().fetchall() assert len(from_db) == 1, "expected excactly one returned row" data_from_db = from_db[0] - auto_form_data.rec_id = data_from_db["un_id"] + auto_form_data.ids.pers_id = data_from_db["pers_id"] auto_form_data.geloescht = data_from_db["geloescht"] auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ "Metadaten_aktualisierung" ] - logger.debug("[AutoForm -- backend] Inserted InitRec successfully") + logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") else: logger.debug("[AutoForm -- backend] Update...") stmt = ( db.grunderfassung_personen.update() - .where(db.grunderfassung_personen.c.pers_id == auto_form_data.rec_id) + .where(db.grunderfassung_personen.c.pers_id == auto_form_data.ids.pers_id) .returning( db.grunderfassung_personen.c.Metadaten_aktualisierung, db.grunderfassung_personen.c.geloescht, @@ -288,8 +293,8 @@ def initrec_person_to_db( ] logger.debug( - "[AutoForm -- backend] Updated InitRec with ID %d successfully", - auto_form_data.rec_id, + "[AutoForm -- backend] Updated InitRec Person with ID %d successfully", + auto_form_data.ids.pers_id, ) return auto_form_data @@ -313,33 +318,34 @@ def initrec_person_from_db( del data_from_db["geloescht"] return InitRec( - rec_id=id_, + ids=EntityIds(pers_id=id_), + # rec_id=id_, # TODO remove geloescht=geloescht, db_data=data_from_db, ) # TODO check removal -# def initrec_company_get_initial_recording( -# id_: RecId, -# ) -> dict[str, Any]: -# logger.debug("[Call backend] get_initial_recording") -# stmt = db.grunderfassung_unternehmen.select().where( -# db.grunderfassung_unternehmen.c.un_id == id_ -# ) -# with db.ENGINE.connect() as conn: -# ret = conn.execute(stmt) +def initrec_company_get_initial_recording( + id_: RecId, +) -> dict[str, Any]: + logger.debug("[Call backend] get_initial_recording") + stmt = db.grunderfassung_unternehmen.select().where( + db.grunderfassung_unternehmen.c.un_id == id_ + ) + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) -# results = ret.mappings().all() -# if not results: -# raise KeyError(f"Database ID {id_} not found") + results = ret.mappings().all() + if not results: + raise KeyError(f"Database ID {id_} not found") -# assert len(results) == 1, "more than one company initial recording obtained" + assert len(results) == 1, "more than one company initial recording obtained" -# row = results[0] -# assert row, "row was not obtained" + row = results[0] + assert row, "row was not obtained" -# return dict(row) + return dict(row) # def initrec_company_delete_initial_recording( @@ -828,7 +834,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=InitRecType.COMPANY, + type=EntityType.COMPANY, ) ) @@ -865,7 +871,7 @@ def _main_page_get_person_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=InitRecType.PERSON, + type=EntityType.PERSON, ) ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index a008ccf..72a6c8a 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -19,10 +19,11 @@ from pydantic import ( from pydantic_core import ErrorDetails from wce_crm.form_defs import FormField -from wce_crm.types import ConsId, ConsultingType, RecId, UserId +from wce_crm.types import EntityIds if TYPE_CHECKING: from wce_crm.gui import Page_Consulting_ConsultingSession # noqa: F401 + from wce_crm.types import ConsId, ConsultingType, EntityType, RecId, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] COLUMN_SEP: Final[str] = "__" @@ -74,33 +75,7 @@ def _parse_json(value: Any) -> str: raise TypeError -# // GUI states and types -def set_page_state( - target: S, - source: S, - deep: bool = False, -) -> None: - """ - transfer all field values generically from 'source' to 'target' - """ - if not (dc.is_dataclass(target) and dc.is_dataclass(source)): - raise TypeError("Both source and atrget must be dataclasses") - - if type(target) is not type(source): - raise TypeError("Both source and target must have the same type") - - for field in dc.fields(source): - if field.name == "child_modules": - continue - - new_value = getattr(source, field.name) - - if deep: - new_value = copy.deepcopy(new_value) - - setattr(target, field.name, new_value) - - +# // interfaces class WrapperModule(Protocol): def _sync_state_to_GUI(self, *args, **kwargs) -> None: """build states for all child modules and use their `load_state` method @@ -178,20 +153,47 @@ class Module(WrapperModule, Protocol): # ) -> None: ... -class AutoFormToDb(Protocol): +class InitRecFormToDb(Protocol): def __call__( self, auto_form_data: InitRec, ) -> InitRec: ... -class AutoFormFromDb(Protocol): +class InitRecFormFromDb(Protocol): def __call__( self, id_: int, ) -> InitRec: ... +# // GUI states and types +def set_page_state( + target: S, + source: S, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and atrget must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + for field in dc.fields(source): + if field.name == "child_modules": + continue + + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) + + @dc.dataclass(slots=True) class Session: user_id: UserId @@ -215,40 +217,45 @@ class Page_NewInitRec_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) -class Page_InitRecCompany_State(PageState[Module]): - session: Session - un_id: RecId | None = None - locked: bool - - -@dc.dataclass(slots=True, kw_only=True) -class AutoFormConfig: +class InitRecFormConfig: model: type[FlatBaseModel] - to_db: AutoFormToDb - from_db: AutoFormFromDb + to_db: InitRecFormToDb + from_db: InitRecFormFromDb form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = True @dc.dataclass(slots=True, kw_only=True) -class AutoForm_State(PageState[Module]): +class InitRecForm_State(PageState[Module]): session: Session - cfg: AutoFormConfig - rec_id: RecId | None = None + cfg: InitRecFormConfig + ids: EntityIds + ent_type: EntityType + # rec_id: RecId | None = None form_data: dict[str, Any] | None = None locked: bool geloescht: bool = False @dc.dataclass(slots=True, kw_only=True) -class Page_InitRecPerson_State(PageState[Module]): +class Page_InitRecCompany_State(PageState[Module]): session: Session - pers_id: RecId | None = None - un_id: RecId | None = None + ids: EntityIds + # un_id: RecId | None = None locked: bool +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecPerson_State(PageState[Module]): + session: Session + ids: EntityIds + # pers_id: RecId | None = None + # un_id: RecId | None = None + locked: bool + + +# TODO change to entity IDs @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -264,6 +271,7 @@ class Page_Consulting_State(PageState[Module]): locked: bool +# TODO change to entity IDs @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Linking_State(PageState[Module]): session: Session @@ -279,7 +287,6 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): nutzer_name: str # must be set with the session (always known) beratung_id: int | None # known and not known entries zeitstempel: datetime.datetime | None = None - # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) ansprechpartner: str | None = None kommunikationsweg: str | None = None thema_crm_matrix: str | None = ( @@ -301,7 +308,8 @@ class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"] @dc.dataclass(slots=True, kw_only=True) class Page_CompanyProfile_State(PageState[Module]): session: Session - rec_id: RecId | None = None + ids: EntityIds + # rec_id: RecId | None = None # // Pydantic models @@ -419,7 +427,8 @@ class FlatBaseModel(BaseModel): # ** InitRec class InitRec(BaseModel): - rec_id: RecId | None + ids: EntityIds + # rec_id: RecId | None geloescht: AwareDatetime | None = None db_data: dict[str, Any] diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 2031744..878f6ea 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -84,13 +84,13 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, - AutoForm_State, - AutoFormConfig, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, Grunderfassung_Personen, Grunderfassung_Unternehmen, InitRec, + InitRecForm_State, + InitRecFormConfig, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, @@ -117,11 +117,17 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) -from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType, RecId +from wce_crm.types import ( + CompanyProfileConsultationEntry, + ConsultingType, + EntityIds, + EntityType, + RecId, +) if TYPE_CHECKING: from wce_crm.data_models import ( - AutoForm_State, + InitRecForm_State, Module, WrapperModule, ) @@ -1370,14 +1376,14 @@ def search_widgets_by_key( return hits -class AutoForm(QWidget): +class InitRecForm(QWidget): """a widget, which is managed by a code-defined field definition collection""" update_triggered = Signal() # formular saved (data changed for front page) def __init__( self, - state: AutoForm_State, + state: InitRecForm_State, ) -> None: super().__init__() self.STATE = state @@ -1519,7 +1525,8 @@ class AutoForm(QWidget): index = int(self.id_field_input.text()) except ValueError: index = None - self.STATE.rec_id = index + self.STATE.ids.un_id = index + self.STATE.ids.pers_id = index logger_gui.debug("[Auto-Form] Set index to %s, new state: %s", index, self.STATE) def _disable_save(self) -> None: @@ -1539,13 +1546,13 @@ class AutoForm(QWidget): self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) def _activate_delete(self) -> None: - if self.STATE.rec_id is not None: + if self.STATE.ids.valid(): self.delete_btn.setEnabled(True) else: self.delete_btn.setEnabled(False) def _delete_data(self) -> None: - assert self.STATE.rec_id is not None, "deletion initialised despite no index set" + assert self.STATE.ids.valid(), "deletion initialised despite IDs are not properly set" confirm = get_user_confirmation( self, "Löschen bestätigen", @@ -1592,7 +1599,9 @@ class AutoForm(QWidget): logger_auto_form.debug("[Auto-Form] Set form data...") logger_auto_form.debug("[Auto-Form] Form data:\n%s", pformat(form_data)) self._set_form_data(form_data) - self.STATE.rec_id = id_ + self.STATE.ids.set_id(id_, self.STATE.ent_type) + # TODO remove + # self.STATE.rec_id = id_ self.STATE.form_data = self._get_form_data() logger_auto_form.debug( "####### model dump vs get form data:\n%s\n\n%s", @@ -1602,7 +1611,9 @@ class AutoForm(QWidget): def reset_form(self) -> None: reset_form(self.widget_registry) - self.STATE.rec_id = None + self.STATE.ids.set_id(None, self.STATE.ent_type) + # TODO remove + # self.STATE.rec_id = None self.STATE.form_data = None self.STATE.geloescht = False @@ -1640,7 +1651,7 @@ class AutoForm(QWidget): ) -> None: if section is None or section == "form": if not self.STATE.geloescht: - self._load_from_id(self.STATE.rec_id) + self._load_from_id(self.STATE.ids.get_id(self.STATE.ent_type)) else: self.reset_form() @@ -1666,12 +1677,12 @@ class AutoForm(QWidget): def load_state( self, - new_state: AutoForm_State, + new_state: InitRecForm_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI(None) - def get_state(self) -> AutoForm_State: + def get_state(self) -> InitRecForm_State: self._sync_GUI_to_state() return self.STATE @@ -1680,20 +1691,23 @@ class AutoForm(QWidget): self, data: InitRec, ) -> None: - self.STATE.rec_id = data.rec_id + ent_type = self.STATE.ent_type + self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) + # self.STATE.rec_id = data.rec_id self.STATE.form_data = None self.STATE.geloescht = True if data.geloescht else False self._sync_state_to_GUI(None) # TODO change to correct loading def save_data(self) -> None: - if self.STATE.rec_id is None and self.STATE.geloescht: + relevant_id = self.STATE.ids.get_id(self.STATE.ent_type) + if relevant_id is None and self.STATE.geloescht: logger_auto_form.debug( "[Auto-Form] This was never saved in the database " "and is marked for deletion - ignore." ) return - elif self.STATE.rec_id is not None and self.STATE.geloescht: + elif relevant_id is not None and self.STATE.geloescht: logger_auto_form.debug( "[Auto-Form] This was saved in the database " "and is marked for deletion - fast path." @@ -1701,7 +1715,8 @@ class AutoForm(QWidget): deleted_datetime = datetime.datetime.now(datetime.UTC) assert self.STATE.form_data, "cannot delete entry with uninitialised form data" to_db = InitRec( - rec_id=self.STATE.rec_id, + ids=self.STATE.ids, + # rec_id=self.STATE.rec_id, # TODO remove geloescht=deleted_datetime, db_data={}, ) @@ -1759,7 +1774,8 @@ class AutoForm(QWidget): try: # TODO change to result pattern to_db = InitRec( - rec_id=self.STATE.rec_id, + ids=self.STATE.ids, + # rec_id=self.STATE.rec_id, geloescht=deleted_datetime, db_data=db_data, ) @@ -2904,18 +2920,22 @@ class Page_NewInitRec(QWidget): layout.addWidget(btn_consulting) def _request_initrec_company(self) -> None: + ids = EntityIds(type=EntityType.COMPANY) req_state = Page_InitRecCompany_State( session=self.STATE.session, - un_id=None, + ids=ids, + # un_id=None, # TODO remove locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.company_requested.emit(req_state) def _request_initrec_person(self) -> None: + ids = EntityIds(type=EntityType.PERSON) req_state = Page_InitRecPerson_State( session=self.STATE.session, - pers_id=None, + ids=ids, + # pers_id=None, # TODO remove locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) @@ -2955,7 +2975,7 @@ class Page_NewInitRec(QWidget): pass -CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( +CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[InitRecFormConfig] = InitRecFormConfig( model=Grunderfassung_Unternehmen, to_db=backend.initrec_company_to_db, from_db=backend.initrec_company_from_db, @@ -2966,7 +2986,7 @@ CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( form_fields=INITREC_COMP, ) -CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( +CONFIG_GRUNDERFASSUNG_PERSONEN: Final[InitRecFormConfig] = InitRecFormConfig( model=Grunderfassung_Personen, to_db=backend.initrec_person_to_db, from_db=backend.initrec_person_from_db, @@ -3036,8 +3056,8 @@ class Page_InitRecCompany(QWidget): ) self.comp_profile_btn.setFixedHeight(50) - self.initrec_person_btn = QPushButton("Individualperson erfassen") - self.initrec_person_btn.clicked.connect(self._request_inirec_person) + self.initrec_person_btn = QPushButton("Individualperson erfassen/anzeigen") + self.initrec_person_btn.clicked.connect(self._request_initrec_person) self.initrec_person_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) @@ -3079,13 +3099,16 @@ class Page_InitRecCompany(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) - self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_UNTERNEHMEN - auto_form_state = AutoForm_State( + self.INITREC_FORM_CFG = CONFIG_GRUNDERFASSUNG_UNTERNEHMEN + assert self.STATE.ids.ent_type is EntityType.COMPANY, "mismatch of entity types" + auto_form_state = InitRecForm_State( session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, + cfg=self.INITREC_FORM_CFG, + ids=self.STATE.ids, + ent_type=EntityType.COMPANY, locked=False, ) - self.auto_form = AutoForm(auto_form_state) + self.auto_form = InitRecForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_main_page.emit()) self.auto_form.update_triggered.connect(self._auto_form_updated) @@ -3132,24 +3155,29 @@ class Page_InitRecCompany(QWidget): self._sync_state_to_GUI("banners") def _request_company_profile(self) -> None: - assert self.STATE.un_id is not None, ( + assert self.STATE.ids.un_id is not None, ( "requested company profile for a company which is not known (no ID)" ) req_state = Page_CompanyProfile_State( session=self.STATE.session, - rec_id=self.STATE.un_id, + # rec_id=self.STATE.un_id, + ids=self.STATE.ids, ) logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) self.company_profile_requested.emit(req_state) - def _request_inirec_person(self) -> None: - assert self.STATE.un_id is not None, ( + def _request_initrec_person(self) -> None: + assert self.STATE.ids.un_id is not None, ( "requested initrec of person for a company which is not known (no ID)" ) + if not self.STATE.locked: + self.auto_form.save_data() + req_state = Page_InitRecPerson_State( session=self.STATE.session, - pers_id=None, - un_id=self.STATE.un_id, + ids=self.STATE.ids, + # pers_id=None, # TODO remove + # un_id=self.STATE.un_id, locked=False, ) logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) @@ -3160,13 +3188,16 @@ class Page_InitRecCompany(QWidget): def _sync_state_to_GUI( self, - section: Literal["auto_form", "comp_profile", "banners"] | None, + section: Literal["auto_form", "comp_profile", "initrec_person", "banners"] | None, ) -> None: if section is None or section == "auto_form": - auto_form_state = AutoForm_State( + assert self.STATE.ids.ent_type is EntityType.COMPANY, "mismatch of entity types" + auto_form_state = InitRecForm_State( session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, - rec_id=self.STATE.un_id, + cfg=self.INITREC_FORM_CFG, + ids=self.STATE.ids, + ent_type=EntityType.COMPANY, + # rec_id=self.STATE.un_id, # TODO remove form_data=None, locked=self.STATE.locked, ) @@ -3174,14 +3205,20 @@ class Page_InitRecCompany(QWidget): self.auto_form.load_state(auto_form_state) if section is None or section == "comp_profile": - if self.STATE.un_id is None: - self.comp_profile_btn.setEnabled(False) - else: + if self.STATE.ids.valid(): self.comp_profile_btn.setEnabled(True) + else: + self.comp_profile_btn.setEnabled(False) + + if section is None or section == "initrec_person": + if self.STATE.ids.un_id is not None: + self.initrec_person_btn.setEnabled(True) + else: + self.initrec_person_btn.setEnabled(False) if section is None or section == "banners": self._clear_info_banners() - if self.STATE.un_id is None: + if self.STATE.ids.un_id is None: self._add_info_banner( text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", button_text="", @@ -3189,7 +3226,8 @@ class Page_InitRecCompany(QWidget): def _sync_GUI_to_state(self) -> None: auto_form_state = self.auto_form.get_state() - self.STATE.un_id = auto_form_state.rec_id + self.STATE.ids = auto_form_state.ids + # self.STATE.un_id = auto_form_state.rec_id # TODO remove def load_state( self, @@ -3291,12 +3329,14 @@ class Page_InitRecPerson(QWidget): # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_PERSONEN - auto_form_state = AutoForm_State( + auto_form_state = InitRecForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, + ids=self.STATE.ids, + ent_type=EntityType.PERSON, locked=False, ) - self.auto_form = AutoForm(auto_form_state) + self.auto_form = InitRecForm(auto_form_state) container_layout.addWidget(self.auto_form) self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) self.auto_form.update_triggered.connect(self._auto_form_updated) @@ -3376,10 +3416,12 @@ class Page_InitRecPerson(QWidget): section: Literal["auto_form", "banners"] | None, ) -> None: if section is None or section == "auto_form": - auto_form_state = AutoForm_State( + auto_form_state = InitRecForm_State( session=self.STATE.session, cfg=self.AUTO_FORM_CFG, - rec_id=self.STATE.pers_id, + ids=self.STATE.ids, + ent_type=EntityType.PERSON, + # rec_id=self.STATE.pers_id, # TODO remove form_data=None, locked=self.STATE.locked, ) @@ -3387,7 +3429,7 @@ class Page_InitRecPerson(QWidget): if section is None or section == "banners": self._clear_info_banners() - if self.STATE.pers_id is None: + if self.STATE.ids.pers_id is None: self._add_info_banner( text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", button_text="", @@ -3395,7 +3437,8 @@ class Page_InitRecPerson(QWidget): def _sync_GUI_to_state(self) -> None: auto_form_state = self.auto_form.get_state() - self.STATE.pers_id = auto_form_state.rec_id + self.STATE.ids = auto_form_state.ids + # self.STATE.ids.pers_id = auto_form_state.rec_id # TODO remove def load_state( self, @@ -3420,9 +3463,7 @@ class Page_InitRecPerson(QWidget): class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page - back_requested = Signal( - Page_InitRecCompany_State - ) # back to init rec (bool: reset yes/no) + back_requested = Signal() # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") # update_triggered = Signal() # form saved (data changed for front page) @@ -3473,7 +3514,8 @@ class Page_CompanyProfile(QWidget): back_btn_main.setMinimumWidth(200) back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") - back_btn_step.clicked.connect(self._request_initrec_company) + # back_btn_step.clicked.connect(self._request_initrec_company) # TODO remove + back_btn_step.clicked.connect(lambda: self.back_requested.emit()) back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) @@ -3688,26 +3730,26 @@ class Page_CompanyProfile(QWidget): target_idx = target_layout.count() - 1 target_layout.insertWidget(target_idx, dummy_click_cell) - def _request_initrec_company(self) -> None: - req_state = Page_InitRecCompany_State( - session=self.STATE.session, - un_id=self.STATE.rec_id, - locked=True, - ) - logger_gui.debug("[Page Company Profile] State to call: %s", req_state) - self.back_requested.emit(req_state) + # def _request_initrec_company(self) -> None: + # req_state = Page_InitRecCompany_State( + # session=self.STATE.session, + # un_id=self.STATE.rec_id, + # locked=True, + # ) + # logger_gui.debug("[Page Company Profile] State to call: %s", req_state) + # self.back_requested.emit(req_state) def _request_new_consulting( self, cons_type: ConsultingType, ) -> None: - assert self.STATE.rec_id is not None, ( + assert self.STATE.ids.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=None, - un_id=self.STATE.rec_id, + un_id=self.STATE.ids.un_id, beratungs_typ=cons_type, locked=False, ) @@ -3717,13 +3759,13 @@ class Page_CompanyProfile(QWidget): self, data: CompanyProfileConsultationEntry, ) -> None: - assert self.STATE.rec_id is not None, ( + assert self.STATE.ids.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=data.cons_id, - un_id=self.STATE.rec_id, + un_id=self.STATE.ids.un_id, beratungs_typ=data.cons_type, locked=True, ) @@ -3745,9 +3787,11 @@ class Page_CompanyProfile(QWidget): widget.deleteLater() def _update_consultations(self) -> None: - if self.STATE.rec_id is None: + if self.STATE.ids.un_id is None: return - consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.rec_id) + consultations = backend.companyprofile_page_get_consultations( + un_id=self.STATE.ids.un_id + ) logger_gui.debug( "[Page -- Company Profile] Consultations: %s", pformat(consultations) @@ -3785,7 +3829,7 @@ class Page_CompanyProfile(QWidget): self.add_btn_individual.setEnabled(True) def _sync_state_to_GUI(self) -> None: - if self.STATE.rec_id is None: + if self.STATE.ids.un_id is None: # just clear and do nothing else logger_gui.debug("just reset, ID is None") self.table_master_data.reset() @@ -3794,7 +3838,7 @@ class Page_CompanyProfile(QWidget): return self._enable_add_btns() - init_rec = backend.initrec_company_get_initial_recording(self.STATE.rec_id) + init_rec = backend.initrec_company_get_initial_recording(self.STATE.ids.un_id) ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) assert ma_id is not None, "no ma_id obtained" comp_info = backend.initrec_comp_search_get_info(ma_id) @@ -3809,16 +3853,18 @@ class Page_CompanyProfile(QWidget): self.set_company_name(data["Name:"]) self.update_current_page() - def _sync_GUI_to_state(self) -> None: ... + def _sync_GUI_to_state(self) -> None: + pass - def validate(self) -> None: ... + def validate(self) -> None: + pass def load_state( self, new_state: Page_CompanyProfile_State, ) -> None: - logger_gui.debug("Called function to load with ID: %s", new_state.rec_id) - self.STATE.rec_id = new_state.rec_id + logger_gui.debug("Called function to load with IDs: %s", pformat(new_state.ids)) + self.STATE.ids = new_state.ids self._sync_state_to_GUI() @@ -4015,7 +4061,7 @@ class MasterDataFormWidget(QWidget): class Page_Consulting(QWidget): back_main_requested = Signal() # back to main page - back_requested = Signal(bool) # back to init rec (bool: reset yes/no) + back_requested = Signal() # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") update_triggered = Signal() # form saved (data changed for front page) @@ -4057,7 +4103,7 @@ class Page_Consulting(QWidget): back_btn_main.setMinimumWidth(200) back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") - back_btn_step.clicked.connect(lambda: self.back_requested.emit(False)) + back_btn_step.clicked.connect(lambda: self.back_requested.emit()) back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) @@ -5792,30 +5838,36 @@ class MainWindow(QMainWindow): self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.new_initrec_select) # SITE: 'Grunderfassung Unternehmen' + ids = EntityIds(type=EntityType.COMPANY) initrec_company_state = Page_InitRecCompany_State( - session=self.STATE.session, locked=False + session=self.STATE.session, ids=ids, locked=False ) self.initrec_company = Page_InitRecCompany(initrec_company_state) self.initrec_company.back_main_requested.connect(self.show_main_page) - self.initrec_company.back_requested.connect(self.show_new_entry_select) + self.initrec_company.back_requested.connect(self.simple_go_back) self.initrec_company.update_main_page.connect(self.update_grid) self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person) self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' + ids = EntityIds(type=EntityType.PERSON) initrec_person_state = Page_InitRecPerson_State( - session=self.STATE.session, locked=False + session=self.STATE.session, ids=ids, locked=False ) self.initrec_person = Page_InitRecPerson(initrec_person_state) self.initrec_person.back_main_requested.connect(self.show_main_page) - self.initrec_person.back_requested.connect(self.show_new_entry_select) + self.initrec_person.back_requested.connect(self.simple_go_back) self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' - comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) + comp_profile_state = Page_CompanyProfile_State( + session=self.STATE.session, ids=EntityIds() + ) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) - self.company_profile.back_requested.connect(self.show_page_initrec_company) + # TODO remove + # self.company_profile.back_requested.connect(self.show_page_initrec_company) + self.company_profile.back_requested.connect(self.simple_go_back) self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) # SITE: 'Beratungsgespräch' @@ -5947,18 +5999,22 @@ class MainWindow(QMainWindow): data: types.MainPageEntry, ): target: QWidget - if data.type is types.InitRecType.COMPANY: + if data.type is types.EntityType.COMPANY: + ids = EntityIds(un_id=data.rec_id, type=EntityType.COMPANY) new_state = Page_InitRecCompany_State( session=self.STATE.session, - un_id=data.rec_id, + ids=ids, + # un_id=data.rec_id, # TODO remove locked=True, ) self.initrec_company.load_state(new_state) target = self.initrec_company - elif data.type is types.InitRecType.PERSON: + elif data.type is types.EntityType.PERSON: + ids = EntityIds(pers_id=data.rec_id, type=EntityType.PERSON) new_state = Page_InitRecPerson_State( session=self.STATE.session, - pers_id=data.rec_id, + ids=ids, + # pers_id=data.rec_id, # TODO remove locked=True, ) self.initrec_person.load_state(new_state) diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index dcf3e05..9d4fc34 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -18,7 +18,7 @@ ExtAnId: TypeAlias = int PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] -class InitRecType(enum.IntEnum): +class EntityType(enum.IntEnum): COMPANY = enum.auto() PERSON = enum.auto() @@ -28,10 +28,56 @@ class ConsultingType(enum.StrEnum): INDIVIDUAL = enum.auto() -@dc.dataclass(slots=True) +@dc.dataclass(slots=True, kw_only=True) class EntityIds: un_id: RecId | None = None pers_id: RecId | None = None + type: dc.InitVar[EntityType | None] = None + _ent_type: EntityType | None = dc.field(init=False) + + def __post_init__(self, type: EntityType | None) -> None: + self._ent_type = type + + @property + def ent_type(self) -> EntityType | None: + return self._ent_type + + def any(self) -> bool: + return self.un_id is not None or self.pers_id is not None + + def all(self) -> bool: + return self.un_id is not None and self.pers_id is not None + + def valid(self) -> bool: + if self.ent_type is None: + raise TypeError("Initrec type not defined. Can not determine valid state.") + elif self.ent_type is EntityType.COMPANY: + return self.all() + elif self.ent_type is EntityType.PERSON: + return self.any() + else: + raise RuntimeError( + f"Unknown initrec type {self.ent_type}. This is not supported." + ) + + def set_id( + self, + id_: RecId | None, + ent_type: EntityType, + ) -> None: + if ent_type is EntityType.COMPANY: + self.un_id = id_ + elif ent_type is EntityType.PERSON: + self.pers_id = id_ + + def get_id( + self, + ent_type: EntityType, + ) -> RecId | None: + if ent_type is EntityType.COMPANY: + return self.un_id + elif ent_type is EntityType.PERSON: + return self.pers_id # // database interaction data structures @@ -94,7 +140,7 @@ class MainPageEntry: rec_id: RecId display_name: str Metadaten_aktualisierung: datetime.datetime - type: InitRecType + type: EntityType @dc.dataclass(slots=True) -- 2.34.1 From 416a686877977be7e2280cd49c8a1bf1452b2e00 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 22 Jul 2026 16:19:45 +0200 Subject: [PATCH 52/74] data modelling: extension table pattern --- data_model/project_db_schema.aml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 2738be5..1a716a4 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -6,22 +6,19 @@ Ext_CRM.Ansprechpartner an_id int pk ma_id int -> Ext_CRM.Master(ma_id) +Unternehmen | Es wird nur die ID für die Kontaktliste referenziert. Alle relevanten Infos werden live aus der Kontaktliste geladen via 'ATTACH'. + ma_id int pk -> Ext_CRM.Master(ma_id) + hinzugefuegt_am "timestamp with time zone" + geloescht_am "timestamp with time zone" nullable -Grunderfassung_Unternehmen - un_id int pk {autoIncrement} - geloescht "timestamp with time zone" nullable - Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable - Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} - Metadaten_nutzer varchar(20) nullable - Grunderfassung_fallnummer text nullable - Grunderfassung_notiz text nullable - Partnersuche__un_suche int nullable -> Ext_CRM.Master(ma_id) - Partnersuche__person_suche int nullable -> Ext_CRM.Ansprechpartner(an_id) +Grunderfassung_Personen_Zusatzdaten_Unternehmen + erf_id int pk -> Grunderfassung_Personen(erf_id) + Partnersuche__un_suche int -> Ext_CRM.Master(ma_id) + Partnersuche__person_suche int -> Ext_CRM.Ansprechpartner(an_id) Partnersuche__kanal_aufmerksamkeit text nullable - Grunderfassung_Personen - pers_id int pk {autoIncrement} + erf_id int pk {autoIncrement} geloescht "timestamp with time zone" nullable Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} -- 2.34.1 From fd25576b0e06d6eade515eafae0ee16ce7fc792b Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 23 Jul 2026 17:31:38 +0200 Subject: [PATCH 53/74] sketch for new data model --- data_model/project_db_schema.aml | 37 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 1a716a4..8d0e991 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -1,24 +1,26 @@ -Ext_CRM.Master +Ext_Kontaktliste.Master ma_id int pk - -Ext_CRM.Ansprechpartner +Ext_Kontaktliste.Ansprechpartner an_id int pk - ma_id int -> Ext_CRM.Master(ma_id) + ma_id int -> Ext_Kontaktliste.Master(ma_id) Unternehmen | Es wird nur die ID für die Kontaktliste referenziert. Alle relevanten Infos werden live aus der Kontaktliste geladen via 'ATTACH'. - ma_id int pk -> Ext_CRM.Master(ma_id) + un_id int pk -> Ext_Kontaktliste.Master(ma_id) hinzugefuegt_am "timestamp with time zone" geloescht_am "timestamp with time zone" nullable -Grunderfassung_Personen_Zusatzdaten_Unternehmen - erf_id int pk -> Grunderfassung_Personen(erf_id) - Partnersuche__un_suche int -> Ext_CRM.Master(ma_id) - Partnersuche__person_suche int -> Ext_CRM.Ansprechpartner(an_id) +Grunderfassung_Personen_Unternehmen_Zuordnung + id int pk {autoIncrement} + pers_id int pk -> Grunderfassung_Personen(pers_id) unique=uq_aktive_zuordnung + un_id int -> Unternehmen(un_id) unique=uq_aktive_zuordnung | alias 'Partnersuche__un_suche' + an_id int -> Ext_Kontaktliste.Ansprechpartner(an_id) | alias 'Partnersuche__person_suche Partnersuche__kanal_aufmerksamkeit text nullable + gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` + gueltig_bis "timestamp with time zone" nullable | Soft-Delete Marker (partieller Unique-Index: WHERE deleted_at IS NULL) Grunderfassung_Personen - erf_id int pk {autoIncrement} + pers_id int pk {autoIncrement} geloescht "timestamp with time zone" nullable Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} @@ -70,17 +72,17 @@ Grunderfassung_Personen WeitereInfos__WI_meldung_institution text nullable -Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehören, ein Unternehmen kann mehrere Personen betreuen. - id int pk {autoIncrement} - un_id int -> Grunderfassung_Unternehmen(un_id) - pers_id int -> Grunderfassung_Personen(pers_id) - gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` - gueltig_bis "timestamp with time zone" nullable +# Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehören, ein Unternehmen kann mehrere Personen betreuen. +# id int pk {autoIncrement} +# un_id int -> Grunderfassung_Unternehmen(un_id) +# pers_id int -> Grunderfassung_Personen(pers_id) +# gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` +# gueltig_bis "timestamp with time zone" nullable Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein (pauschal) oder beide (individual) gebunden sein vorgang_id int pk {autoIncrement} - un_id int nullable -> Grunderfassung_Unternehmen(un_id) | Pauschalberatungen sind sowohl für Unternehmen als auch Personen zulässig + un_id int nullable -> Unternehmen(un_id) | Pauschalberatungen sind sowohl für Unternehmen als auch Personen zulässig pers_id int nullable -> Grunderfassung_Personen(pers_id) | sowohl Pauschal- als auch Individualberatung bei Personenbindung möglich titel text beratungs_typ berat_typ(pauschal, individual) @@ -89,7 +91,6 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein geloescht "timestamp with time zone" nullable # temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind - Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden beratung_id int pk {autoIncrement} vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. -- 2.34.1 From 868db09d9438a72b542e5b3bbb907b91850925cb Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 23 Jul 2026 19:17:49 +0200 Subject: [PATCH 54/74] iteration for new data model --- data_model/project_db_schema.aml | 19 +- prototypes/tests.py | 59 +- src/wce_crm/backend/backend.py | 427 ++++----- src/wce_crm/data_models.py | 78 +- src/wce_crm/db.py | 177 ++-- src/wce_crm/gui.py | 1376 ++++++++++++++++-------------- src/wce_crm/logging.py | 21 +- src/wce_crm/types.py | 164 +++- 8 files changed, 1338 insertions(+), 983 deletions(-) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 8d0e991..12c8bfc 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -7,23 +7,24 @@ Ext_Kontaktliste.Ansprechpartner Unternehmen | Es wird nur die ID für die Kontaktliste referenziert. Alle relevanten Infos werden live aus der Kontaktliste geladen via 'ATTACH'. un_id int pk -> Ext_Kontaktliste.Master(ma_id) - hinzugefuegt_am "timestamp with time zone" - geloescht_am "timestamp with time zone" nullable + erstellt "timestamp with time zone" + geloescht "timestamp with time zone" nullable -Grunderfassung_Personen_Unternehmen_Zuordnung +Personen_Unternehmen_Zuordnung id int pk {autoIncrement} pers_id int pk -> Grunderfassung_Personen(pers_id) unique=uq_aktive_zuordnung un_id int -> Unternehmen(un_id) unique=uq_aktive_zuordnung | alias 'Partnersuche__un_suche' an_id int -> Ext_Kontaktliste.Ansprechpartner(an_id) | alias 'Partnersuche__person_suche Partnersuche__kanal_aufmerksamkeit text nullable - gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` - gueltig_bis "timestamp with time zone" nullable | Soft-Delete Marker (partieller Unique-Index: WHERE deleted_at IS NULL) + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} + geloescht "timestamp with time zone" nullable | Soft-Delete Marker (partieller Unique-Index: WHERE geloescht IS NULL) Grunderfassung_Personen pers_id int pk {autoIncrement} - geloescht "timestamp with time zone" nullable - Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable - Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} + geloescht "timestamp with time zone" nullable Metadaten_wiedereintrittsdatum timestamp nullable Metadaten_nutzer varchar(20) nullable Grunderfassung_fallnummer text nullable @@ -70,6 +71,8 @@ Grunderfassung_Personen WeitereInfos__WI_deutsch_sprache text nullable WeitereInfos__WI_gueltigkeit_aufenthaltstitel text nullable WeitereInfos__WI_meldung_institution text nullable +# Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable +# Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} # Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehören, ein Unternehmen kann mehrere Personen betreuen. diff --git a/prototypes/tests.py b/prototypes/tests.py index 4e61039..a2cb9a6 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -19,9 +19,48 @@ from PySide6.QtCore import QDate, Qt from wce_crm import constants, db from wce_crm.backend import backend +from wce_crm.types import NewEntityIds, RecordingType # %% -int("test") +raw_ent_ids = NewEntityIds() +db_ent_ids = NewEntityIds(un_id=3, an_id=2) + +print(f"{raw_ent_ids=}\n{db_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") +# %% +raw_ent_ids.update(db_ent_ids) +print(f"{raw_ent_ids=}") +db_ent_ids = NewEntityIds(un_id=None, an_id=2, link_id=1) +raw_ent_ids.update(db_ent_ids) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") +raw_ent_ids.update(NewEntityIds()) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") + +# %% +raw_ent_ids.update(NewEntityIds(un_id=None, pers_id=2)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") +print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") +raw_ent_ids.update(NewEntityIds(un_id=None, an_id=2, link_id=3, pers_id=2)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") +print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") +raw_ent_ids.update(NewEntityIds(un_id=2, pers_id=None, an_id=2, link_id=3)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") +print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") # %% db_path = constants.Config.DB_PATH_MAIN @@ -29,22 +68,10 @@ crm_path = constants.Config.DB_PATH_CRM engine = sql.create_engine(f"sqlite:///{str(db_path)}") engine_crm = sql.create_engine(f"sqlite:///{str(crm_path)}") # %% -stmt = sql.select( - db.grunderfassung.c.erfassung_id, - db.grunderfassung.c.Partnersuche__un_suche, -) - -with engine.connect() as conn: - res = conn.execute(stmt) - - -for r in res: - print(r) -# %% # %% -backend._main_page_get_company_list() +backend._main_page_get_company_list() # type: ignore # %% @@ -218,12 +245,12 @@ class FlatBaseModel(BaseModel): return final_nested_data - def to_db(self) -> Dict[str, Any]: + def to_db(self) -> dict[str, Any]: """Ausgang für die DB: Flach, Listen sind JSON-Strings.""" nested = super().model_dump() return self.__flatten_dict(nested, serialize_lists=True) - def to_gui(self) -> Dict[str, Any]: + def to_gui(self) -> dict[str, Any]: """Ausgang für die GUI: Flach, aber Listen bleiben Python-Listen.""" nested = super().model_dump() return self.__flatten_dict(nested, serialize_lists=False) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index f7e5ee0..5ee9f50 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -24,11 +24,11 @@ from wce_crm.types import ( CompanyProfileConsultations, ConsultingType, ContactPersonInfo, - EntityIds, - EntityType, LinkingConsultationsEntryCompany, LinkingConsultationsEntryPerson, MainPageEntry, + NewEntityIds, + RecordingType, ) if TYPE_CHECKING: @@ -152,200 +152,210 @@ def initrec_comp_contact_person_search_get_info( # conn.execute(stmt, data) -def initrec_company_to_db( - auto_form_data: InitRec, -) -> InitRec: - logger.debug("[AutoForm -- backend] Call database saving routine...") +# def initrec_company_to_db( +# auto_form_data: InitRec, +# ) -> InitRec: +# logger.debug("[AutoForm -- backend] Call database saving routine...") - dump_data = copy.deepcopy(auto_form_data.db_data) - dump_data["geloescht"] = auto_form_data.geloescht +# dump_data = copy.deepcopy(auto_form_data.db_data) +# dump_data["geloescht"] = auto_form_data.geloescht - with db.ENGINE.begin() as conn: - if auto_form_data.ids.un_id is None: - logger.debug("[AutoForm -- backend] Insert...") +# with db.ENGINE.begin() as conn: +# if auto_form_data.ids.un_id is None: +# logger.debug("[AutoForm -- backend] Insert...") - stmt = db.grunderfassung_unternehmen.insert().returning( - db.grunderfassung_unternehmen.c.un_id, - db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - db.grunderfassung_unternehmen.c.geloescht, - ) - ret = conn.execute(stmt, dump_data) +# stmt = db.grunderfassung_unternehmen.insert().returning( +# db.grunderfassung_unternehmen.c.un_id, +# db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, +# db.grunderfassung_unternehmen.c.geloescht, +# ) +# ret = conn.execute(stmt, dump_data) - from_db = ret.mappings().fetchall() - assert len(from_db) == 1, "expected excactly one returned row" - data_from_db = from_db[0] - auto_form_data.ids.un_id = data_from_db["un_id"] - auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excactly one returned row" +# data_from_db = from_db[0] +# auto_form_data.ids.un_id = data_from_db["un_id"] +# auto_form_data.geloescht = data_from_db["geloescht"] +# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ +# "Metadaten_aktualisierung" +# ] - logger.debug("[AutoForm -- backend] Inserted InitRec Company successfully") +# logger.debug("[AutoForm -- backend] Inserted InitRec Company successfully") - else: - logger.debug("[AutoForm -- backend] Update...") - stmt = ( - db.grunderfassung_unternehmen.update() - .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.ids.un_id) - .returning( - db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - db.grunderfassung_unternehmen.c.geloescht, - ) - ) - ret = conn.execute(stmt, dump_data) +# else: +# logger.debug("[AutoForm -- backend] Update...") +# stmt = ( +# db.grunderfassung_unternehmen.update() +# .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.ids.un_id) +# .returning( +# db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, +# db.grunderfassung_unternehmen.c.geloescht, +# ) +# ) +# ret = conn.execute(stmt, dump_data) - from_db = ret.mappings().fetchall() - assert len(from_db) == 1, "expected excatly one returned row" - data_from_db = from_db[0] +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excatly one returned row" +# data_from_db = from_db[0] - auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] +# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ +# "Metadaten_aktualisierung" +# ] - logger.debug( - "[AutoForm -- backend] Updated InitRec Company with ID %d successfully", - auto_form_data.ids.un_id, - ) +# logger.debug( +# "[AutoForm -- backend] Updated InitRec Company with ID %d successfully", +# auto_form_data.ids.un_id, +# ) - return auto_form_data +# return auto_form_data -def initrec_company_from_db( - id_: RecId, -) -> InitRec: - logger.debug("[AutoForm -- backend] Call database loading routine...") - stmt = db.grunderfassung_unternehmen.select().where( - db.grunderfassung_unternehmen.c.un_id == id_ - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) +# def initrec_company_from_db( +# id_: RecId, +# ) -> InitRec: +# logger.debug("[AutoForm -- backend] Call database loading routine...") +# stmt = db.grunderfassung_unternehmen.select().where( +# db.grunderfassung_unternehmen.c.un_id == id_ +# ) +# with db.ENGINE.connect() as conn: +# ret = conn.execute(stmt) - from_db = ret.mappings().all() - assert len(from_db) == 1, "not excactly one company initial recording obtained" - data_from_db = dict(from_db[0]) +# from_db = ret.mappings().all() +# assert len(from_db) == 1, "not excactly one company initial recording obtained" +# data_from_db = dict(from_db[0]) - geloescht = data_from_db["geloescht"] - del data_from_db["geloescht"] +# geloescht = data_from_db["geloescht"] +# del data_from_db["geloescht"] - return InitRec( - ids=EntityIds(un_id=id_), - # rec_id=id_, # TODO remove - geloescht=geloescht, - db_data=data_from_db, - ) +# return InitRec( +# ids=EntityIds(un_id=id_), +# # rec_id=id_, # TODO remove +# geloescht=geloescht, +# db_data=data_from_db, +# ) -def initrec_person_to_db( - auto_form_data: InitRec, -) -> InitRec: - logger.debug("[AutoForm -- backend] Call database saving routine...") +# def initrec_person_to_db( +# auto_form_data: InitRec, +# ) -> InitRec: +# logger.debug("[AutoForm -- backend] Call database saving routine...") - dump_data = copy.deepcopy(auto_form_data.db_data) - dump_data["geloescht"] = auto_form_data.geloescht +# dump_data = copy.deepcopy(auto_form_data.db_data) +# dump_data["geloescht"] = auto_form_data.geloescht - with db.ENGINE.begin() as conn: - if auto_form_data.ids.pers_id is None: - logger.debug("[AutoForm -- backend] Insert...") +# with db.ENGINE.begin() as conn: +# if auto_form_data.ids.pers_id is None: +# logger.debug("[AutoForm -- backend] Insert...") - stmt = sa.insert(db.grunderfassung_personen).returning( - db.grunderfassung_personen.c.pers_id, - db.grunderfassung_personen.c.Metadaten_aktualisierung, - db.grunderfassung_personen.c.geloescht, - ) - stmt_compiled = str(stmt.compile(db.ENGINE)) - logger.debug( - "[AutoForm -- backend] Data to insert:\nStatement: %s\n%s", - stmt_compiled, - pformat(dump_data), - ) +# stmt = sa.insert(db.t_grunderfassung_personen).returning( +# db.t_grunderfassung_personen.c.pers_id, +# db.t_grunderfassung_personen.c.Metadaten_aktualisierung, +# db.t_grunderfassung_personen.c.geloescht, +# ) +# stmt_compiled = str(stmt.compile(db.ENGINE)) +# logger.debug( +# "[AutoForm -- backend] Data to insert:\nStatement: %s\n%s", +# stmt_compiled, +# pformat(dump_data), +# ) - ret = conn.execute(stmt, dump_data) +# ret = conn.execute(stmt, dump_data) - from_db = ret.mappings().fetchall() - assert len(from_db) == 1, "expected excactly one returned row" - data_from_db = from_db[0] - auto_form_data.ids.pers_id = data_from_db["pers_id"] - auto_form_data.geloescht = data_from_db["geloescht"] - auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excactly one returned row" +# data_from_db = from_db[0] +# auto_form_data.ids.pers_id = data_from_db["pers_id"] +# auto_form_data.geloescht = data_from_db["geloescht"] +# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ +# "Metadaten_aktualisierung" +# ] - logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") +# logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") - else: - logger.debug("[AutoForm -- backend] Update...") - stmt = ( - db.grunderfassung_personen.update() - .where(db.grunderfassung_personen.c.pers_id == auto_form_data.ids.pers_id) - .returning( - db.grunderfassung_personen.c.Metadaten_aktualisierung, - db.grunderfassung_personen.c.geloescht, - ) - ) - ret = conn.execute(stmt, dump_data) +# else: +# logger.debug("[AutoForm -- backend] Update...") +# stmt = ( +# db.t_grunderfassung_personen.update() +# .where(db.t_grunderfassung_personen.c.pers_id == auto_form_data.ids.pers_id) +# .returning( +# db.t_grunderfassung_personen.c.Metadaten_aktualisierung, +# db.t_grunderfassung_personen.c.geloescht, +# ) +# ) +# ret = conn.execute(stmt, dump_data) - from_db = ret.mappings().fetchall() - assert len(from_db) == 1, "expected excatly one returned row" - data_from_db = from_db[0] +# from_db = ret.mappings().fetchall() +# assert len(from_db) == 1, "expected excatly one returned row" +# data_from_db = from_db[0] - auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] +# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ +# "Metadaten_aktualisierung" +# ] - logger.debug( - "[AutoForm -- backend] Updated InitRec Person with ID %d successfully", - auto_form_data.ids.pers_id, - ) +# logger.debug( +# "[AutoForm -- backend] Updated InitRec Person with ID %d successfully", +# auto_form_data.ids.pers_id, +# ) - return auto_form_data +# return auto_form_data -def initrec_person_from_db( - id_: RecId, -) -> InitRec: - logger.debug("[AutoForm -- backend] Call database loading routine...") - stmt = db.grunderfassung_personen.select().where( - db.grunderfassung_personen.c.pers_id == id_ - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) +# def initrec_person_from_db( +# id_: RecId, +# ) -> InitRec: +# logger.debug("[AutoForm -- backend] Call database loading routine...") +# stmt = db.t_grunderfassung_personen.select().where( +# db.t_grunderfassung_personen.c.pers_id == id_ +# ) +# with db.ENGINE.connect() as conn: +# ret = conn.execute(stmt) - from_db = ret.mappings().all() - assert len(from_db) == 1, "not excactly one company initial recording obtained" - data_from_db = dict(from_db[0]) +# from_db = ret.mappings().all() +# assert len(from_db) == 1, "not excactly one company initial recording obtained" +# data_from_db = dict(from_db[0]) - geloescht = data_from_db["geloescht"] - del data_from_db["geloescht"] +# geloescht = data_from_db["geloescht"] +# del data_from_db["geloescht"] - return InitRec( - ids=EntityIds(pers_id=id_), - # rec_id=id_, # TODO remove - geloescht=geloescht, - db_data=data_from_db, - ) +# return InitRec( +# ids=EntityIds(pers_id=id_), +# # rec_id=id_, # TODO remove +# geloescht=geloescht, +# db_data=data_from_db, +# ) # TODO check removal -def initrec_company_get_initial_recording( - id_: RecId, -) -> dict[str, Any]: - logger.debug("[Call backend] get_initial_recording") - stmt = db.grunderfassung_unternehmen.select().where( - db.grunderfassung_unternehmen.c.un_id == id_ - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) +# def initrec_company_get_initial_recording( +# id_: RecId, +# ) -> dict[str, Any]: +# logger.debug("[Call backend] get_initial_recording") +# stmt = db.grunderfassung_unternehmen.select().where( +# db.grunderfassung_unternehmen.c.un_id == id_ +# ) +# with db.ENGINE.connect() as conn: +# ret = conn.execute(stmt) - results = ret.mappings().all() - if not results: - raise KeyError(f"Database ID {id_} not found") +# results = ret.mappings().all() +# if not results: +# raise KeyError(f"Database ID {id_} not found") - assert len(results) == 1, "more than one company initial recording obtained" +# assert len(results) == 1, "more than one company initial recording obtained" - row = results[0] - assert row, "row was not obtained" +# row = results[0] +# assert row, "row was not obtained" - return dict(row) +# return dict(row) + + +def page_initrec_to_db( + data: InitRec, +) -> InitRec: ... + + +def page_initrec_from_db( + ids: NewEntityIds, +) -> InitRec: ... # def initrec_company_delete_initial_recording( @@ -444,7 +454,7 @@ def page_consulting_to_db( pformat(insert_data_process), ) - stmt = db.beratung_vorgang.insert() + stmt = db.t_beratung_vorgang.insert() ret = conn.execute(stmt, insert_data_process) if ret.rowcount == 0: raise IOError("Entry was not inserted correctly") @@ -460,8 +470,8 @@ def page_consulting_to_db( consultation_data.vorgang_id, ) update_data_process = consultation_data.model_dump(exclude={"beratungen"}) - stmt = db.beratung_vorgang.update().where( - db.beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id + stmt = db.t_beratung_vorgang.update().where( + db.t_beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id ) conn.execute(stmt, update_data_process) @@ -487,8 +497,8 @@ def page_consulting_to_db( logger.debug( "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) ) - stmt = db.beratung_einzelberatung.update().where( - db.beratung_einzelberatung.c.beratung_id == sa.bindparam("b_beratung_id") + stmt = db.t_beratung_einzelberatung.update().where( + db.t_beratung_einzelberatung.c.beratung_id == sa.bindparam("b_beratung_id") ) conn.execute(stmt, rows_for_db_update) @@ -497,8 +507,8 @@ def page_consulting_to_db( logger.debug( "[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert) ) - stmt = db.beratung_einzelberatung.insert().returning( - db.beratung_einzelberatung.c.beratung_id + stmt = db.t_beratung_einzelberatung.insert().returning( + db.t_beratung_einzelberatung.c.beratung_id ) res = conn.execute(stmt, rows_for_db_insert) new_cons_session_ids = cast(list[int], [row[0] for row in res.fetchall()]) @@ -518,12 +528,14 @@ def page_consulting_delete( dump_data: dict[str, Any] = {"geloescht": timestamp_deleted} with db.ENGINE.begin() as conn: - stmt = db.beratung_vorgang.update().where(db.beratung_vorgang.c.vorgang_id == cons_id) + stmt = db.t_beratung_vorgang.update().where( + db.t_beratung_vorgang.c.vorgang_id == cons_id + ) conn.execute(stmt, dump_data) - stmt = db.beratung_einzelberatung.update().where( - db.beratung_einzelberatung.c.vorgang_id == cons_id, - db.beratung_einzelberatung.c.geloescht.is_(None), + stmt = db.t_beratung_einzelberatung.update().where( + db.t_beratung_einzelberatung.c.vorgang_id == cons_id, + db.t_beratung_einzelberatung.c.geloescht.is_(None), ) conn.execute(stmt, dump_data) @@ -537,7 +549,9 @@ def page_consulting_from_db( with db.ENGINE.connect() as conn: # get the consultation process - stmt = db.beratung_vorgang.select().where(db.beratung_vorgang.c.vorgang_id == cons_id) + stmt = db.t_beratung_vorgang.select().where( + db.t_beratung_vorgang.c.vorgang_id == cons_id + ) ret = conn.execute(stmt) results = ret.mappings().all() @@ -570,9 +584,9 @@ def page_consulting_from_db( # get all consultation sessions of this process, only the ones # which are not marked as deleted - stmt = db.beratung_einzelberatung.select().where( - db.beratung_einzelberatung.c.vorgang_id == cons_id, - db.beratung_einzelberatung.c.geloescht.is_(None), + stmt = db.t_beratung_einzelberatung.select().where( + db.t_beratung_einzelberatung.c.vorgang_id == cons_id, + db.t_beratung_einzelberatung.c.geloescht.is_(None), ) ret = conn.execute(stmt) cons_sessions = ret.mappings().all() @@ -604,9 +618,12 @@ def page_consulting_from_db( # // consulting page interaction +# TODO change to correct logic def page_consulting_linking_companies( un_id: RecId | None, ) -> list[LinkingConsultationsEntryCompany]: + raise RuntimeError("TODOs to implement") + with db.ENGINE.connect() as conn: q_relevant_companies = sa.select( db.grunderfassung_unternehmen.c.un_id, @@ -624,12 +641,12 @@ def page_consulting_linking_companies( stmt = sa.select( q_relevant_companies.c.un_id, - db.ext_crm_master_attach.c.ma_unternehmensname, + db.t_ext_crm_master_attach.c.ma_unternehmensname, ).select_from( q_relevant_companies.join( - db.ext_crm_master_attach, + db.t_ext_crm_master_attach, q_relevant_companies.c.Partnersuche__un_suche - == db.ext_crm_master_attach.c.ma_id, + == db.t_ext_crm_master_attach.c.ma_id, ) ) @@ -660,20 +677,20 @@ def page_consulting_linking_persons( # else: list just the persons associated with the company person_select = sa.select( - db.grunderfassung_personen.c.pers_id, - db.grunderfassung_personen.c.Stammdaten__name, - db.grunderfassung_personen.c.Stammdaten__vorname, - db.grunderfassung_personen.c.Stammdaten__ort, + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, ) if un_id is not None: person_select = person_select.select_from( - db.grunderfassung_personen.join( - db.zuordnung_personen_unternehmen, - db.grunderfassung_personen.c.pers_id - == db.zuordnung_personen_unternehmen.c.pers_id, + db.t_grunderfassung_personen.join( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, ) - ).where(db.zuordnung_personen_unternehmen.c.un_id == un_id) + ).where(db.t_zuordnung_personen_unternehmen.c.un_id == un_id) ret = conn.execute(person_select) person_names = ret.mappings().all() @@ -711,8 +728,8 @@ def page_consulting_eastablish_link( ) -> None: stmt = ( - sa.update(db.beratung_vorgang) - .where(db.beratung_vorgang.c.vorgang_id == vorgang_id) + sa.update(db.t_beratung_vorgang) + .where(db.t_beratung_vorgang.c.vorgang_id == vorgang_id) .values(un_id=un_id, pers_id=pers_id) ) @@ -725,11 +742,13 @@ def companyprofile_page_get_consultations( ) -> CompanyProfileConsultations: logger.debug("[Call backend] _companyprofile_page_get_consultations") stmt = sa.select( - db.beratung_vorgang.c.vorgang_id, - db.beratung_vorgang.c.aktualisiert, - db.beratung_vorgang.c.titel, - db.beratung_vorgang.c.beratungs_typ, - ).where(db.beratung_vorgang.c.un_id == un_id, db.beratung_vorgang.c.geloescht.is_(None)) + db.t_beratung_vorgang.c.vorgang_id, + db.t_beratung_vorgang.c.aktualisiert, + db.t_beratung_vorgang.c.titel, + db.t_beratung_vorgang.c.beratungs_typ, + ).where( + db.t_beratung_vorgang.c.un_id == un_id, db.t_beratung_vorgang.c.geloescht.is_(None) + ) with db.ENGINE.connect() as conn: res = conn.execute(stmt) @@ -782,17 +801,17 @@ def initrec_link_person_company( if unlink: deleted_ts = datetime.datetime.now(tz=datetime.UTC) stmt = ( - sa.update(db.zuordnung_personen_unternehmen) + sa.update(db.t_zuordnung_personen_unternehmen) .where( - db.zuordnung_personen_unternehmen.c.un_id == un_id, - db.zuordnung_personen_unternehmen.c.pers_id == pers_id, - db.zuordnung_personen_unternehmen.c.gueltig_bis.is_(None), + db.t_zuordnung_personen_unternehmen.c.un_id == un_id, + db.t_zuordnung_personen_unternehmen.c.pers_id == pers_id, + db.t_zuordnung_personen_unternehmen.c.gueltig_bis.is_(None), ) .values(gueltig_bis=deleted_ts) ) else: stmt = ( - sqlite_insert(db.zuordnung_personen_unternehmen) + sqlite_insert(db.t_zuordnung_personen_unternehmen) .values(un_id=un_id, pers_id=pers_id) .on_conflict_do_nothing( index_elements=["un_id", "pers_id"], @@ -805,8 +824,12 @@ def initrec_link_person_company( # // main page interaction +# TODO change to correct logic def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") + + raise RuntimeError("TODOs to implement") + stmt = sa.select( db.grunderfassung_unternehmen.c.un_id, db.grunderfassung_unternehmen.c.Partnersuche__un_suche, @@ -834,7 +857,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=EntityType.COMPANY, + type=RecordingType.COMPANY, ) ) @@ -844,11 +867,11 @@ def _main_page_get_company_list() -> list[MainPageEntry]: def _main_page_get_person_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") stmt = sa.select( - db.grunderfassung_personen.c.pers_id, - db.grunderfassung_personen.c.Stammdaten__vorname, - db.grunderfassung_personen.c.Stammdaten__name, - db.grunderfassung_personen.c.Metadaten_aktualisierung, - ).where(db.grunderfassung_personen.c.geloescht.is_(None)) + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Metadaten_aktualisierung, + ).where(db.t_grunderfassung_personen.c.geloescht.is_(None)) with db.ENGINE.connect() as conn: res = conn.execute(stmt) @@ -871,7 +894,7 @@ def _main_page_get_person_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=EntityType.PERSON, + type=RecordingType.PERSON, ) ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 72a6c8a..83c83bb 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -19,11 +19,11 @@ from pydantic import ( from pydantic_core import ErrorDetails from wce_crm.form_defs import FormField -from wce_crm.types import EntityIds +from wce_crm.types import NewEntityIds if TYPE_CHECKING: - from wce_crm.gui import Page_Consulting_ConsultingSession # noqa: F401 - from wce_crm.types import ConsId, ConsultingType, EntityType, RecId, UserId + from wce_crm.gui import InitRecForm, Page_Consulting_ConsultingSession # noqa: F401 + from wce_crm.types import ConsId, ConsultingType, RecId, RecordingType, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] COLUMN_SEP: Final[str] = "__" @@ -163,7 +163,7 @@ class InitRecFormToDb(Protocol): class InitRecFormFromDb(Protocol): def __call__( self, - id_: int, + ids: NewEntityIds, ) -> InitRec: ... @@ -177,7 +177,7 @@ def set_page_state( transfer all field values generically from 'source' to 'target' """ if not (dc.is_dataclass(target) and dc.is_dataclass(source)): - raise TypeError("Both source and atrget must be dataclasses") + raise TypeError("Both source and target must be dataclasses") if type(target) is not type(source): raise TypeError("Both source and target must have the same type") @@ -212,47 +212,61 @@ class Page_MainPage_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) -class Page_NewInitRec_State(PageState[Module]): +class Page_NewEntry_State(PageState[Module]): session: Session +# !! there must be exactly one InitRecForm for each recording type @dc.dataclass(slots=True, kw_only=True) -class InitRecFormConfig: - model: type[FlatBaseModel] - to_db: InitRecFormToDb - from_db: InitRecFormFromDb +class Page_InitRec_State(PageState["InitRecForm"]): + session: Session + ids: NewEntityIds = dc.field(default_factory=NewEntityIds) + record_type: RecordingType + # un_id: RecId | None = None + locked: bool + geloescht: bool = False + form_data: dict[RecordingType, dict[str, Any]] = dc.field(default_factory=dict) + pydantic_models: dict[RecordingType, type[FlatBaseModel]] = dc.field(default_factory=dict) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_Form_Config: + pydantic_model: type[FlatBaseModel] + # to_db: InitRecFormToDb # TODO remove + # from_db: InitRecFormFromDb # TODO remove form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() - add_buttons: bool = True + add_buttons: bool = False @dc.dataclass(slots=True, kw_only=True) -class InitRecForm_State(PageState[Module]): +class Page_InitRec_Form_State(PageState[Module]): session: Session - cfg: InitRecFormConfig - ids: EntityIds - ent_type: EntityType + cfg: Page_InitRec_Form_Config + ids: NewEntityIds + recording_type: RecordingType # rec_id: RecId | None = None - form_data: dict[str, Any] | None = None + form_data: dict[str, Any] = dc.field(default_factory=dict) locked: bool geloescht: bool = False -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecCompany_State(PageState[Module]): - session: Session - ids: EntityIds - # un_id: RecId | None = None - locked: bool +# TODO remove +# @dc.dataclass(slots=True, kw_only=True) +# class Page_InitRecCompany_State(PageState[Module]): +# session: Session +# ids: EntityIds +# # un_id: RecId | None = None +# locked: bool -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecPerson_State(PageState[Module]): - session: Session - ids: EntityIds - # pers_id: RecId | None = None - # un_id: RecId | None = None - locked: bool +# @dc.dataclass(slots=True, kw_only=True) +# class Page_InitRecPerson_State(PageState[Module]): +# session: Session +# ids: EntityIds +# # pers_id: RecId | None = None +# # un_id: RecId | None = None +# locked: bool # TODO change to entity IDs @@ -308,7 +322,7 @@ class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"] @dc.dataclass(slots=True, kw_only=True) class Page_CompanyProfile_State(PageState[Module]): session: Session - ids: EntityIds + ids: NewEntityIds # rec_id: RecId | None = None @@ -427,10 +441,10 @@ class FlatBaseModel(BaseModel): # ** InitRec class InitRec(BaseModel): - ids: EntityIds + ids: NewEntityIds # rec_id: RecId | None geloescht: AwareDatetime | None = None - db_data: dict[str, Any] + db_data: dict[RecordingType, dict[str, Any]] class Grunderfassung_Unternehmen(FlatBaseModel): diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index af0734e..1c6b883 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -255,7 +255,7 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) -ext_crm_master_attach: Table = Table( +t_ext_crm_master_attach: Table = Table( "Master", MD_CRM, Column("ma_id", sa.Integer, nullable=False, unique=True), @@ -285,9 +285,71 @@ ext_crm_master_attach: Table = Table( schema="kontaktliste", ) +t_ext_crm_contact_person_attach: Table = Table( + "Ansprechpartner", + MD_CRM, + Column("an_id", sa.Integer, nullable=False, unique=True), + Column("ma_id", sa.ForeignKey("Master.ma_id")), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("st_id", sa.Integer, nullable=False), + Column("an_sachgebiet", sa.Text, nullable=True), + Column("an_anrede", sa.Text, nullable=True), + Column("an_titel", sa.Text, nullable=True), + Column("an_nachname", sa.Text, nullable=True), + Column("an_vorname", sa.Text, nullable=True), + Column("an_position", sa.Text, nullable=True), + Column("an_mail", sa.Text, nullable=True), + Column("an_festnetz", sa.Text, nullable=True), + Column("an_mobil", sa.Text, nullable=True), + Column("an_faxnummer", sa.Text, nullable=True), + Column("an_hauptansprechpartner", sa.Text, nullable=True), + Column("an_anrede_anschrift", sa.Text, nullable=True), + Column("an_bemerkung", sa.Text, nullable=True), + Column("an_aktualisierung_datum", SafeDateTime, nullable=True), + Column("an_aktualisierung_nutzer", sa.Text, nullable=True), + Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True), + Column("an_ersteintrag_datum", SafeDateTime, nullable=True), + Column("an_archiviert", sa.Boolean, nullable=True, default=0), + schema="kontaktliste", +) -grunderfassung_unternehmen: Table = Table( - "grunderfassung_unternehmen", +# grunderfassung_unternehmen: Table = Table( +# "grunderfassung_unternehmen", +# MD_MAIN, +# Column( +# "un_id", +# sa.Integer, +# primary_key=True, +# autoincrement=True, +# ), +# Column( +# "geloescht", +# UTCDateTime, +# nullable=True, +# ), +# Column( +# "Metadaten_erstellung", +# UTCDateTime, +# nullable=True, +# default=lambda: datetime.datetime.now(datetime.UTC), +# ), +# Column( +# "Metadaten_aktualisierung", +# UTCDateTime, +# nullable=True, +# default=lambda: datetime.datetime.now(datetime.UTC), +# onupdate=lambda: datetime.datetime.now(datetime.UTC), +# ), +# Column("Metadaten_nutzer", sa.String(20), nullable=True), +# Column("Grunderfassung_fallnummer", sa.Text, nullable=True), +# Column("Grunderfassung_notiz", sa.Text, nullable=True), +# Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True, default=None), +# Column("Partnersuche__person_suche", sa.Integer, nullable=True, default=None), +# Column("Partnersuche__un_suche", sa.Integer, nullable=True, default=None), +# ) + +t_unternehmen: Table = Table( + "Unternehmen", MD_MAIN, Column( "un_id", @@ -295,34 +357,21 @@ grunderfassung_unternehmen: Table = Table( primary_key=True, autoincrement=True, ), + Column( + "erstellt", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), Column( "geloescht", UTCDateTime, nullable=True, ), - Column( - "Metadaten_erstellung", - UTCDateTime, - nullable=True, - default=lambda: datetime.datetime.now(datetime.UTC), - ), - Column( - "Metadaten_aktualisierung", - UTCDateTime, - nullable=True, - default=lambda: datetime.datetime.now(datetime.UTC), - onupdate=lambda: datetime.datetime.now(datetime.UTC), - ), - Column("Metadaten_nutzer", sa.String(20), nullable=True), - Column("Grunderfassung_fallnummer", sa.Text, nullable=True), - Column("Grunderfassung_notiz", sa.Text, nullable=True), - Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True, default=None), - Column("Partnersuche__person_suche", sa.Integer, nullable=True, default=None), - Column("Partnersuche__un_suche", sa.Integer, nullable=True, default=None), ) -grunderfassung_personen: Table = Table( - "grunderfassung_personen", +t_grunderfassung_personen: Table = Table( + "Grunderfassung_Personen", MD_MAIN, Column( "pers_id", @@ -330,24 +379,37 @@ grunderfassung_personen: Table = Table( primary_key=True, autoincrement=True, ), + Column( + "erstellt", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "aktualisiert", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), Column( "geloescht", UTCDateTime, nullable=True, ), - Column( - "Metadaten_erstellung", - UTCDateTime, - nullable=True, - default=lambda: datetime.datetime.now(datetime.UTC), - ), - Column( - "Metadaten_aktualisierung", - UTCDateTime, - nullable=True, - default=lambda: datetime.datetime.now(datetime.UTC), - onupdate=lambda: datetime.datetime.now(datetime.UTC), - ), + # Column( + # "Metadaten_erstellung", + # UTCDateTime, + # nullable=True, + # default=lambda: datetime.datetime.now(datetime.UTC), + # ), + # Column( + # "Metadaten_aktualisierung", + # UTCDateTime, + # nullable=True, + # default=lambda: datetime.datetime.now(datetime.UTC), + # onupdate=lambda: datetime.datetime.now(datetime.UTC), + # ), Column("Metadaten_nutzer", sa.String(20), nullable=True), Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), Column("Arbeitserfahrung", sa.Text, nullable=True), @@ -398,8 +460,8 @@ grunderfassung_personen: Table = Table( Column("WeitereInfos__WI_meldung_institution", sa.Text, nullable=True), ) -zuordnung_personen_unternehmen: Table = Table( - "zuordnung_personen_unternehmen", +t_zuordnung_personen_unternehmen: Table = Table( + "Personen_Unternehmen_Zuordnung", MD_MAIN, Column( "id", @@ -407,31 +469,50 @@ zuordnung_personen_unternehmen: Table = Table( primary_key=True, autoincrement=True, ), - Column("un_id", sa.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=False), - Column("pers_id", sa.ForeignKey("grunderfassung_personen.pers_id"), nullable=False), + Column("un_id", sa.ForeignKey("Unternehmen.un_id"), nullable=False), + Column("an_id", sa.Integer, nullable=False), + Column("pers_id", sa.ForeignKey("Grunderfassung_Personen.pers_id"), nullable=False), Column( - "gueltig_ab", + "erstellt", UTCDateTime, nullable=False, default=lambda: datetime.datetime.now(datetime.UTC), ), Column( - "gueltig_bis", + "aktualisiert", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "geloescht", UTCDateTime, nullable=True, ), + # Column( + # "gueltig_ab", + # UTCDateTime, + # nullable=False, + # default=lambda: datetime.datetime.now(datetime.UTC), + # ), + # Column( + # "gueltig_bis", + # UTCDateTime, + # nullable=True, + # ), Index( "uq_aktive_zuordnung", "un_id", "pers_id", unique=True, - sqlite_where=sql.text("gueltig_bis IS NULL"), + sqlite_where=sql.text("geloescht IS NULL"), ), ) -beratung_vorgang: Table = Table( - "beratung_vorgang", +t_beratung_vorgang: Table = Table( + "Beratung_Vorgang", MD_MAIN, Column( "vorgang_id", @@ -464,8 +545,8 @@ beratung_vorgang: Table = Table( ) -beratung_einzelberatung: Table = Table( - "beratung_einzelberatung", +t_beratung_einzelberatung: Table = Table( + "Beratung_Einzelberatung", MD_MAIN, Column( "beratung_id", diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 878f6ea..9c82681 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -74,6 +74,7 @@ from PySide6.QtWidgets import ( QStackedWidget, QTableWidget, QTableWidgetItem, + QToolButton, QVBoxLayout, QWidget, ) @@ -89,17 +90,16 @@ from wce_crm.data_models import ( Grunderfassung_Personen, Grunderfassung_Unternehmen, InitRec, - InitRecForm_State, - InitRecFormConfig, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, Page_Consulting_State, Page_Consulting_Table_State, - Page_InitRecCompany_State, - Page_InitRecPerson_State, + Page_InitRec_Form_Config, + Page_InitRec_Form_State, + Page_InitRec_State, Page_MainPage_State, - Page_NewInitRec_State, + Page_NewEntry_State, Session, set_page_state, translate_pydantic_errors, @@ -111,24 +111,25 @@ from wce_crm.form_defs import ( FormFieldType, ) from wce_crm.logging import ( - logger_auto_form, logger_custom_fields, logger_get_data, logger_gui, + logger_initrecform, logger_page_consulting, + logger_page_initrec, ) from wce_crm.types import ( CompanyProfileConsultationEntry, ConsultingType, - EntityIds, - EntityType, + NewEntityIds, RecId, + RecordingType, ) if TYPE_CHECKING: from wce_crm.data_models import ( - InitRecForm_State, Module, + Page_InitRec_Form_State, WrapperModule, ) from wce_crm.types import ( @@ -1383,7 +1384,7 @@ class InitRecForm(QWidget): def __init__( self, - state: InitRecForm_State, + state: Page_InitRec_Form_State, ) -> None: super().__init__() self.STATE = state @@ -1437,21 +1438,21 @@ class InitRecForm(QWidget): self.registry_button.setFixedHeight(35) self.main_layout.addWidget(self.registry_button) - self.test_button = QPushButton("Initialisiere Laden") - self.test_button.clicked.connect(self._load_from_id) - self.test_button.setFixedHeight(35) - self.test_button.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.main_layout.addWidget(self.test_button) + # self.test_button = QPushButton("Initialisiere Laden") + # self.test_button.clicked.connect(self.load_from_ids) + # self.test_button.setFixedHeight(35) + # self.test_button.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # self.main_layout.addWidget(self.test_button) button_get = QPushButton("GET DATA") button_get.setFixedHeight(35) - button_get.clicked.connect(self._get_form_data) + button_get.clicked.connect(self._get_data) self.main_layout.addWidget(button_get) button_set = QPushButton("SET DATA") button_set.setFixedHeight(35) - button_set.clicked.connect(self._set_form_data) + button_set.clicked.connect(self._set_data) self.main_layout.addWidget(button_set) id_field_layout = QHBoxLayout() @@ -1489,11 +1490,11 @@ class InitRecForm(QWidget): self.save_btn = self.edit_buttons.save_btn self.delete_btn = self.edit_buttons.delete_btn - self.modify_btn.modify_activated.connect(self.unlock_form) - self.modify_btn.modify_deactivated.connect(self.lock_form) - self.save_btn.clicked.connect(self.save_data) + self.modify_btn.modify_activated.connect(self.unlock) + self.modify_btn.modify_deactivated.connect(self.lock) + # self.save_btn.clicked.connect(self.save_data) self.reset_btn.clicked.connect(self.reset_form) - self.delete_btn.clicked.connect(self._delete_data) + # self.delete_btn.clicked.connect(self._delete_data) self.main_layout.addWidget(self.edit_buttons) @@ -1512,8 +1513,8 @@ class InitRecForm(QWidget): self.debug_form_data: dict[str, Any] = {} - logger_auto_form.debug( - "[Auto-Form] Initialised Auto Form Widget. Registry:%s", + logger_initrecform.debug( + "[InitRecForm] Initialised Auto Form Widget. Registry:%s", pformat_registry(self.widget_registry), ) @@ -1527,119 +1528,123 @@ class InitRecForm(QWidget): index = None self.STATE.ids.un_id = index self.STATE.ids.pers_id = index - logger_gui.debug("[Auto-Form] Set index to %s, new state: %s", index, self.STATE) + logger_gui.debug("[InitRecForm] Set index to %s, new state: %s", index, self.STATE) - def _disable_save(self) -> None: - assert self.save_btn is not None - assert self.edit_buttons is not None - self.save_btn.setEnabled(False) - self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) + # def _disable_save(self) -> None: + # assert self.save_btn is not None + # assert self.edit_buttons is not None + # self.save_btn.setEnabled(False) + # self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) - def _enable_save( - self, - timeout: int = 3000, - ) -> None: - assert self.save_btn is not None - assert self.edit_buttons is not None - QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) - QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) - self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) + # def _enable_save( + # self, + # timeout: int = 3000, + # ) -> None: + # assert self.save_btn is not None + # assert self.edit_buttons is not None + # QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + # QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + # self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) - def _activate_delete(self) -> None: - if self.STATE.ids.valid(): - self.delete_btn.setEnabled(True) - else: - self.delete_btn.setEnabled(False) + def relevant_ids_set(self) -> bool: + return self.STATE.ids.valid(self.STATE.recording_type) - def _delete_data(self) -> None: - assert self.STATE.ids.valid(), "deletion initialised despite IDs are not properly set" - confirm = get_user_confirmation( - self, - "Löschen bestätigen", - "Soll der Eintrag wirklich gelöscht werden?", - ) - if not confirm: - return + # def _activate_delete(self) -> None: + # if self.STATE.ids.valid(): + # self.delete_btn.setEnabled(True) + # else: + # self.delete_btn.setEnabled(False) - self.STATE.geloescht = True - self.save_data() - self.update_triggered.emit() + # def _delete_data(self) -> None: + # assert self.STATE.ids.valid(), "deletion initialised despite IDs are not properly set" + # confirm = get_user_confirmation( + # self, + # "Löschen bestätigen", + # "Soll der Eintrag wirklich gelöscht werden?", + # ) + # if not confirm: + # return - def _load_from_id( - self, - id_: RecId | None, - ) -> None: - if DEBUG_NO_DATABASE: - return + # self.STATE.geloescht = True + # self.save_data() + # self.update_triggered.emit() - logger_auto_form.info("[Auto-Form] Load data method...") - logger_auto_form.debug("[Auto-Form] Lookup ID: %s", id_) - self.reset_form() + # !! must be part of the managing page + # def load_from_ids( + # self, + # ids: NewEntityIds, + # ) -> None: + # if DEBUG_NO_DATABASE: + # return - if id_ is None: - logger_auto_form.debug("[Auto-Form] Loading: Lookup ID NULL. Do nothing!") - return - logger_auto_form.debug("Load from DB:") - returned = self.cfg.from_db(id_) + # logger_initrecform.info("[InitRecForm] Load data method...") + # logger_initrecform.debug("[InitRecForm] Lookup ID: %s", ids) + # self.reset_form() - # TODO add path to avoid unnecessary loading (where the same ID is loaded - # twice in a row) -- use an internal marker (not state-related) + # if ids.empty(self.STATE.recording_type): + # logger_initrecform.debug("[InitRecForm] Loading: Lookup ID NULL. Do nothing!") + # return + # logger_initrecform.debug("Load from DB:") + # from_db = self.cfg.from_db(ids) - # logger_auto_form.debug( - # "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) - # ) - logger_auto_form.debug( - "[Auto-Form] Loaded data dict:\n%s Passing to Pydantic...", - pformat(returned.db_data), - ) - model = self.cfg.model(**returned.db_data) - logger_auto_form.debug("[Auto-Form] Loaded to Pydantic.") - logger_auto_form.debug("[Auto-Form] Convert to GUI structure...") - form_data = model.to_gui() - logger_auto_form.debug("[Auto-Form] Set form data...") - logger_auto_form.debug("[Auto-Form] Form data:\n%s", pformat(form_data)) - self._set_form_data(form_data) - self.STATE.ids.set_id(id_, self.STATE.ent_type) - # TODO remove - # self.STATE.rec_id = id_ - self.STATE.form_data = self._get_form_data() - logger_auto_form.debug( - "####### model dump vs get form data:\n%s\n\n%s", - pformat(model.model_dump(mode="python")), - pformat(self.STATE.form_data), - ) + # # TODO add path to avoid unnecessary loading (where the same ID is loaded + # # twice in a row) -- use an internal marker (not state-related) + + # # logger_auto_form.debug( + # # "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + # # ) + # logger_initrecform.debug( + # "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", + # pformat(from_db.db_data), + # ) + # model = self.cfg.pydantic_model(**from_db.db_data) + # logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") + # logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") + # form_data = model.to_gui() + # logger_initrecform.debug("[InitRecForm] Set form data...") + # logger_initrecform.debug("[InitRecForm] Form data:\n%s", pformat(form_data)) + # self._set_data(form_data) + # # self.STATE.ids.set_id(id_, self.STATE.ent_type) + # self.STATE.ids.update(from_db.ids) + # self.STATE.form_data = self._get_data() + # logger_initrecform.debug( + # "[InitRecForm] Model dump vs. get form data:\n%s\n\n%s", + # pformat(model.model_dump(mode="python")), + # pformat(self.STATE.form_data), + # ) def reset_form(self) -> None: reset_form(self.widget_registry) - self.STATE.ids.set_id(None, self.STATE.ent_type) + # self.STATE.ids.set_id(None, self.STATE.ent_type) + self.STATE.ids.update(NewEntityIds()) # TODO remove # self.STATE.rec_id = None - self.STATE.form_data = None + self.STATE.form_data.clear() self.STATE.geloescht = False - def lock_form(self) -> None: + def lock(self) -> None: self.STATE.locked = True self._sync_state_to_GUI("locking") - def unlock_form(self) -> None: + def unlock(self) -> None: self.STATE.locked = False self._sync_state_to_GUI("locking") - def _get_form_data(self) -> dict[str, Any]: + def _get_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) - logger_auto_form.debug("[Auto-Form] Call get form data:") - logger_auto_form.debug("[Auto-Form] Form Data:\n%s", pformat(form_data)) + logger_initrecform.debug("[InitRecForm] Call get form data:") + logger_initrecform.debug("[InitRecForm] Form Data:\n%s", pformat(form_data)) if DEBUG_GET_SET: self.debug_form_data = form_data return form_data - def _set_form_data( + def _set_data( self, data: dict[str, Any], ) -> None: - logger_auto_form.debug("[Auto-Form] Call set form data...") + logger_initrecform.debug("[InitRecForm] Call set form data...") if DEBUG_GET_SET: data = self.debug_form_data @@ -1649,9 +1654,11 @@ class InitRecForm(QWidget): self, section: Literal["form", "locking"] | None, ) -> None: + # TODO this should be initialised by states analogous to consulting sessions + # !! loading from IDs is only performed on top-level modules aka pages if section is None or section == "form": if not self.STATE.geloescht: - self._load_from_id(self.STATE.ids.get_id(self.STATE.ent_type)) + self.load_from_ids(self.STATE.ids) else: self.reset_form() @@ -1664,12 +1671,13 @@ class InitRecForm(QWidget): if self.edit_buttons is not None: self.edit_buttons.set_modify_state(True, set_delete_btn=False) unlock_form(self.widget_registry) - self._activate_delete() + # self._activate_delete() def _sync_GUI_to_state(self) -> None: - form_data = self._get_form_data() + form_data = self._get_data() # TODO change usage of user ID (needs changes in model and backend) - form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name + if self.SESSION_USER_KEY in form_data: + form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name self.STATE.form_data = form_data def validate(self) -> list[str]: @@ -1677,12 +1685,12 @@ class InitRecForm(QWidget): def load_state( self, - new_state: InitRecForm_State, + new_state: Page_InitRec_Form_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI(None) - def get_state(self) -> InitRecForm_State: + def get_state(self) -> Page_InitRec_Form_State: self._sync_GUI_to_state() return self.STATE @@ -1691,121 +1699,125 @@ class InitRecForm(QWidget): self, data: InitRec, ) -> None: - ent_type = self.STATE.ent_type - self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) + # ent_type = self.STATE.recording_type + # self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) + self.STATE.ids.update(data.ids) # self.STATE.rec_id = data.rec_id - self.STATE.form_data = None + self.STATE.form_data.clear() self.STATE.geloescht = True if data.geloescht else False self._sync_state_to_GUI(None) # TODO change to correct loading + # !! must be part of the managing page def save_data(self) -> None: - relevant_id = self.STATE.ids.get_id(self.STATE.ent_type) - if relevant_id is None and self.STATE.geloescht: - logger_auto_form.debug( - "[Auto-Form] This was never saved in the database " - "and is marked for deletion - ignore." - ) - return - elif relevant_id is not None and self.STATE.geloescht: - logger_auto_form.debug( - "[Auto-Form] This was saved in the database " - "and is marked for deletion - fast path." - ) - deleted_datetime = datetime.datetime.now(datetime.UTC) - assert self.STATE.form_data, "cannot delete entry with uninitialised form data" - to_db = InitRec( - ids=self.STATE.ids, - # rec_id=self.STATE.rec_id, # TODO remove - geloescht=deleted_datetime, - db_data={}, - ) - returned = self.cfg.to_db(to_db) - self._load_from_pydantic(returned) - msg_box = get_message_box( - QMessageBox.Icon.Information, - "Löschen erfolgreich", - "Der Eintrag wurde erfolgreich gelöscht", - ) - msg_box.exec() + pass - return + # def save_data(self) -> None: + # if self.STATE.ids.empty() and self.STATE.geloescht: + # logger_initrecform.debug( + # "[InitRecForm] This was never saved in the database " + # "and is marked for deletion - ignore." + # ) + # return + # elif not self.STATE.ids.empty() and self.STATE.geloescht: + # logger_initrecform.debug( + # "[InitRecForm] This was saved in the database " + # "and is marked for deletion - fast path." + # ) + # deleted_datetime = datetime.datetime.now(datetime.UTC) + # assert self.STATE.form_data, "cannot delete entry with uninitialised form data" + # to_db = InitRec( + # ids=self.STATE.ids, + # geloescht=deleted_datetime, + # db_data={}, + # ) + # returned = self.cfg.to_db(to_db) + # self._load_from_pydantic(returned) + # msg_box = get_message_box( + # QMessageBox.Icon.Information, + # "Löschen erfolgreich", + # "Der Eintrag wurde erfolgreich gelöscht", + # ) + # msg_box.exec() - self._disable_save() - errors = self.validate() + # return - if errors: - logger_auto_form.error( - "[Auto-Form] Error during GUI validation phase:\n%s", pformat(errors) - ) - GUI_pydantic_validation_error_handling(errors) - self._enable_save() - return + # self._disable_save() + # errors = self.validate() - logger_auto_form.info("[Auto-Form] Success! All data is valid") - logger_auto_form.info("[Auto-Form] Get form data call...") - state = self.get_state() - form_data = state.form_data - assert form_data, "form data not set" + # if errors: + # logger_initrecform.error( + # "[InitRecForm] Error during GUI validation phase:\n%s", pformat(errors) + # ) + # GUI_pydantic_validation_error_handling(errors) + # self._enable_save() + # return - logger_auto_form.debug("[Auto-Form] Get form data\n%s", pformat(form_data)) + # logger_initrecform.info("[InitRecForm] Success! All data is valid") + # logger_initrecform.info("[InitRecForm] Get form data call...") + # state = self.get_state() + # form_data = state.form_data + # assert form_data, "form data not set" - try: - logger_auto_form.debug("[Auto-Form] Call Pydantic...") - validated_data = self.cfg.model(**form_data) - except ValidationError as e: - logger_auto_form.error( - "[Auto-Form] Error during Pydantic validation phase:\n%s", pformat(e.errors()) - ) - pydantic_validation_error_handling(e) - else: - if DEBUG_NO_DATABASE: - return - # !! this code is only called if the 'try' block was successful - # save data to database - db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) - logger_auto_form.debug( - "[Auto-Form] Form data with 'exlude' (must be saved in the database):\n%s", - pformat(db_data), - ) - deleted_datetime: datetime.datetime | None = None - if self.STATE.geloescht: - deleted_datetime = datetime.datetime.now(datetime.UTC) - try: - # TODO change to result pattern - to_db = InitRec( - ids=self.STATE.ids, - # rec_id=self.STATE.rec_id, - geloescht=deleted_datetime, - db_data=db_data, - ) - returned = self.cfg.to_db(to_db) + # logger_initrecform.debug("[InitRecForm] Get form data\n%s", pformat(form_data)) - self._load_from_pydantic(returned) - self.update_triggered.emit() - except Exception as err: - msg_box = get_message_box( - QMessageBox.Icon.Warning, - "Speichern fehlgeschlagen", - ( - "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " - "unten angefügt." - ), - detailed_text=str(err), - ) - msg_box.exec() - else: - logger_auto_form.info("[Auto-Form] Data saved successfully") - msg_box = get_message_box( - QMessageBox.Icon.Information, - "Speichern erfolgreich", - "Die Daten wurden erfolgreich gespeichert", - ) - msg_box.exec() + # try: + # logger_initrecform.debug("[InitRecForm] Call Pydantic...") + # validated_data = self.cfg.pydantic_model(**form_data) + # except ValidationError as e: + # logger_initrecform.error( + # "[InitRecForm] Error during Pydantic validation phase:\n%s", + # pformat(e.errors()), + # ) + # pydantic_validation_error_handling(e) + # else: + # if DEBUG_NO_DATABASE: + # return + # # !! this code is only called if the 'try' block was successful + # # save data to database + # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + # logger_initrecform.debug( + # "[InitRecForm] Form data with 'exlude' (must be saved in the database):\n%s", + # pformat(db_data), + # ) + # deleted_datetime: datetime.datetime | None = None + # if self.STATE.geloescht: + # deleted_datetime = datetime.datetime.now(datetime.UTC) + # try: + # # TODO change to result pattern + # to_db = InitRec( + # ids=self.STATE.ids, + # # rec_id=self.STATE.rec_id, + # geloescht=deleted_datetime, + # db_data=db_data, + # ) + # returned = self.cfg.to_db(to_db) - finally: - # always re-enable save, even if error occurred - self._enable_save() + # self._load_from_pydantic(returned) + # self.update_triggered.emit() + # except Exception as err: + # msg_box = get_message_box( + # QMessageBox.Icon.Warning, + # "Speichern fehlgeschlagen", + # ( + # "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + # "unten angefügt." + # ), + # detailed_text=str(err), + # ) + # msg_box.exec() + # else: + # logger_initrecform.info("[InitRecForm] Data saved successfully") + # msg_box = get_message_box( + # QMessageBox.Icon.Information, + # "Speichern erfolgreich", + # "Die Daten wurden erfolgreich gespeichert", + # ) + # msg_box.exec() + + # finally: + # # always re-enable save, even if error occurred + # self._enable_save() class FlexibleDateInput(QWidget): @@ -2867,17 +2879,19 @@ class MainPage_ClickableCell(ClickableCell): self.clicked.emit(self.data_record) -class Page_NewInitRec(QWidget): +class Page_NewEntry(QWidget): """view for new entry for initial recording ("Grunderfassung")""" back_requested = Signal() # Signal back button - company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen" - person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson" + # TODO re-enable with correct settings + # company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen" + # person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson" consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" + new_initrec_requested = Signal(Page_InitRec_State) # TODO: rename def __init__( self, - state: Page_NewInitRec_State, + state: Page_NewEntry_State, ): super().__init__() self.STATE = state @@ -2911,35 +2925,46 @@ class Page_NewInitRec(QWidget): btn_consulting.setFixedHeight(40) btn_consulting.clicked.connect(self._request_consulting) + btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →") + btn_newinitrec.setFixedWidth(300) + btn_newinitrec.setFixedHeight(40) + btn_newinitrec.clicked.connect(self._request_new_initrec) + layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) + layout.addSpacing(20) + layout.addWidget(btn_newinitrec) + layout.addSpacing(20) layout.addWidget(btn_company) layout.addWidget(btn_person) layout.addSpacing(10) layout.addWidget(btn_consulting) + # TODO configure correctly def _request_initrec_company(self) -> None: - ids = EntityIds(type=EntityType.COMPANY) - req_state = Page_InitRecCompany_State( - session=self.STATE.session, - ids=ids, - # un_id=None, # TODO remove - locked=False, - ) - logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) - self.company_requested.emit(req_state) + logger_gui.debug("[Page -- NewEntry] Call for company...") + # ids = EntityIds(type=RecordingType.COMPANY) + # req_state = Page_InitRecCompany_State( + # session=self.STATE.session, + # ids=ids, + # # un_id=None, # TODO remove + # locked=False, + # ) + # logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) + # self.company_requested.emit(req_state) def _request_initrec_person(self) -> None: - ids = EntityIds(type=EntityType.PERSON) - req_state = Page_InitRecPerson_State( - session=self.STATE.session, - ids=ids, - # pers_id=None, # TODO remove - locked=False, - ) - logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) - self.person_requested.emit(req_state) + logger_gui.debug("[Page -- NewEntry] Call for person...") + # ids = EntityIds(type=RecordingType.PERSON) + # req_state = Page_InitRecPerson_State( + # session=self.STATE.session, + # ids=ids, + # # pers_id=None, # TODO remove + # locked=False, + # ) + # logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) + # self.person_requested.emit(req_state) def _request_consulting(self) -> None: req_state = Page_Consulting_State( @@ -2951,6 +2976,15 @@ class Page_NewInitRec(QWidget): logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.consulting_requested.emit(req_state) + def _request_new_initrec(self) -> None: + req_state = Page_InitRec_State( + session=self.STATE.session, + record_type=RecordingType.COMPANY, + locked=False, + ) + logger_gui.debug("[Page -- NewInitRec] State to call: %s", req_state) + self.new_initrec_requested.emit(req_state) + def _sync_state_to_GUI(self) -> None: pass @@ -2962,12 +2996,12 @@ class Page_NewInitRec(QWidget): def load_state( self, - new_state: Page_NewInitRec_State, + new_state: Page_NewEntry_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI() - def get_state(self) -> Page_NewInitRec_State: + def get_state(self) -> Page_NewEntry_State: self._sync_GUI_to_state() return self.STATE @@ -2975,26 +3009,28 @@ class Page_NewInitRec(QWidget): pass -CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[InitRecFormConfig] = InitRecFormConfig( - model=Grunderfassung_Unternehmen, - to_db=backend.initrec_company_to_db, - from_db=backend.initrec_company_from_db, +CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Unternehmen, + # to_db=backend.initrec_company_to_db, # TODO remove + # from_db=backend.initrec_company_from_db, # TODO remove ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", ), form_fields=INITREC_COMP, + add_buttons=False, ) -CONFIG_GRUNDERFASSUNG_PERSONEN: Final[InitRecFormConfig] = InitRecFormConfig( - model=Grunderfassung_Personen, - to_db=backend.initrec_person_to_db, - from_db=backend.initrec_person_from_db, +CONFIG_GRUNDERFASSUNG_PERSONEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Personen, + # to_db=backend.initrec_person_to_db, # TODO remove + # from_db=backend.initrec_person_from_db, # TODO remove ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", ), form_fields=INITREC_PERSON, + add_buttons=False, ) CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { @@ -3002,263 +3038,63 @@ CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { } -class Page_InitRecCompany(QWidget): - back_main_requested = Signal() # back to main page - back_requested = Signal() # back button - update_main_page = Signal() # form saved (data changed for front page) - company_profile_requested = Signal(Page_CompanyProfile_State) - initrec_person_requested = Signal(Page_InitRecPerson_State) # TODO add corresponding call +class CollapsibleBox(QWidget): + def __init__(self, title="", parent=None): + super().__init__(parent) - def __init__( + # 1. Header-Button mit Pfeil + self.toggle_button = QToolButton() + self.toggle_button.setText(f" {title}") + self.toggle_button.setCheckable(True) + self.toggle_button.setChecked(False) + self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.toggle_button.setArrowType(Qt.ArrowType.RightArrow) + self.toggle_button.setStyleSheet( + "QToolButton { border: none; font-weight: bold; font-size: 13px; }" + ) + self.toggle_button.toggled.connect(self._on_toggle) + + # 2. Inhaltsbereich (standardmäßig ausgeblendet) + self.content_area = QWidget() + self.content_area.setVisible(False) + + # Layout des Box-Containers + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 5, 0, 5) + main_layout.addWidget(self.toggle_button) + main_layout.addWidget(self.content_area) + + def _on_toggle(self, checked: bool): + # Pfeilrichtung ändern & Inhalt ein-/ausblenden + self.toggle_button.setArrowType( + Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow + ) + self.content_area.setVisible(checked) + + def setContentLayout( self, - state: Page_InitRecCompany_State, + layout: QLayout, ): - super().__init__() - self.STATE = state + self.content_area.setLayout(layout) - # Hauptlayout der Seite - outer_layout = QHBoxLayout(self) - vert_layout = QVBoxLayout() - # main_layout.setContentsMargins(0, 0, 0, 0) - outer_layout.addStretch(1) - outer_layout.addLayout(vert_layout, stretch=100) - # outer_layout.addWidget(scroll_area, stretch=100) - outer_layout.addStretch(1) - # Optional: Damit der Container oben am Rand klebt - outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - # --- HEADER --- - header_container = QWidget() - header_container.setMinimumWidth(700) - header_container.setMaximumWidth(1000) - header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - header_layout = QVBoxLayout(header_container) - header_layout.setContentsMargins(0, 0, 0, 10) - - back_btn_main = QPushButton("← Zurück zur Übersicht") - back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) - back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - back_btn_main.setMinimumWidth(200) - back_btn_main.setMaximumWidth(200) - back_btn_step = QPushButton("← Zurück") - back_btn_step.clicked.connect(lambda: self.back_requested.emit()) - back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - back_btn_step.setMinimumWidth(200) - back_btn_step.setMaximumWidth(200) - - title = QLabel("Grunderfassung Unternehmen") - title.setStyleSheet("font-size: 20px; font-weight: bold;") - - self.comp_profile_btn = QPushButton("Unternehmensprofil") - self.comp_profile_btn.clicked.connect(self._request_company_profile) - self.comp_profile_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.comp_profile_btn.setFixedHeight(50) - - self.initrec_person_btn = QPushButton("Individualperson erfassen/anzeigen") - self.initrec_person_btn.clicked.connect(self._request_initrec_person) - self.initrec_person_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.initrec_person_btn.setFixedHeight(50) - - header_layout.setSpacing(5) - header_layout.addWidget(back_btn_step) - header_layout.addWidget(back_btn_main) - header_layout.addWidget(title) - header_layout.addSpacing(15) - header_layout.addWidget(self.comp_profile_btn) - header_layout.addWidget(self.initrec_person_btn) - vert_layout.addWidget(header_container) - - # --- BANNER --- - self.info_banners_container = QWidget() - self.info_banners: list[InfoBanner] = [] - self.info_banners_layout = QVBoxLayout(self.info_banners_container) - self.info_banners_layout.setContentsMargins(0, 0, 0, 0) - self.info_banners_layout.setSpacing(6) - header_layout.addWidget(self.info_banners_container) - - # --- MAIN CONTENT --- - container = QWidget() - # SCROLL AREA - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setMinimumWidth(700) - scroll_area.setMaximumWidth(1000) - scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - # optional: remove frame to look more modern - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - scroll_area.setWidget(container) - - vert_layout.addWidget(scroll_area) - - container_layout = QVBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - - # --- AUTO FORM LAYOUT --- - container_layout.addSpacing(20) - self.INITREC_FORM_CFG = CONFIG_GRUNDERFASSUNG_UNTERNEHMEN - assert self.STATE.ids.ent_type is EntityType.COMPANY, "mismatch of entity types" - auto_form_state = InitRecForm_State( - session=self.STATE.session, - cfg=self.INITREC_FORM_CFG, - ids=self.STATE.ids, - ent_type=EntityType.COMPANY, - locked=False, - ) - self.auto_form = InitRecForm(auto_form_state) - container_layout.addWidget(self.auto_form) - self.auto_form.update_triggered.connect(lambda: self.update_main_page.emit()) - self.auto_form.update_triggered.connect(self._auto_form_updated) - - container_layout.addSpacing(15) - - # --- CUSTOM LOGIC --- - # ** fill 'Kontaktperson -> Namen Unternehmen' - search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") - assert len(search_res) == 1 - search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) - self.search_widget_trigger = cast( - QLineEdit, search_widget.company_widgets["ma_unternehmensname"] - ) - - def _clear_info_banners(self) -> None: - while self.info_banners: - banner = self.info_banners[0] - self.info_banners_layout.removeWidget(banner) - self.info_banners.remove(banner) - banner.deleteLater() - - def _add_info_banner( + def setContentWidget( self, - text: str, - button_text: str, - button_func: Callable[[], None] | None = None, - ) -> None: - if button_text and button_func is None: - raise ValueError("Can not add button without calling function") - - banner = InfoBanner( - text=text, - button_text=button_text, - ) - if button_func is not None: - banner.btn_clicked.connect(button_func) - self.info_banners.append(banner) - self.info_banners_layout.addWidget(banner) - - def _auto_form_updated(self) -> None: - self._sync_GUI_to_state() - self._sync_state_to_GUI("comp_profile") - self._sync_state_to_GUI("banners") - - def _request_company_profile(self) -> None: - assert self.STATE.ids.un_id is not None, ( - "requested company profile for a company which is not known (no ID)" - ) - req_state = Page_CompanyProfile_State( - session=self.STATE.session, - # rec_id=self.STATE.un_id, - ids=self.STATE.ids, - ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) - self.company_profile_requested.emit(req_state) - - def _request_initrec_person(self) -> None: - assert self.STATE.ids.un_id is not None, ( - "requested initrec of person for a company which is not known (no ID)" - ) - if not self.STATE.locked: - self.auto_form.save_data() - - req_state = Page_InitRecPerson_State( - session=self.STATE.session, - ids=self.STATE.ids, - # pers_id=None, # TODO remove - # un_id=self.STATE.un_id, - locked=False, - ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) - self.initrec_person_requested.emit(req_state) - - def reset(self) -> None: - self.auto_form.reset_form() - - def _sync_state_to_GUI( - self, - section: Literal["auto_form", "comp_profile", "initrec_person", "banners"] | None, - ) -> None: - if section is None or section == "auto_form": - assert self.STATE.ids.ent_type is EntityType.COMPANY, "mismatch of entity types" - auto_form_state = InitRecForm_State( - session=self.STATE.session, - cfg=self.INITREC_FORM_CFG, - ids=self.STATE.ids, - ent_type=EntityType.COMPANY, - # rec_id=self.STATE.un_id, # TODO remove - form_data=None, - locked=self.STATE.locked, - ) - logger_gui.debug(">>>>>>>>>>>>> AutoForm State:\n%s", pformat(auto_form_state)) - self.auto_form.load_state(auto_form_state) - - if section is None or section == "comp_profile": - if self.STATE.ids.valid(): - self.comp_profile_btn.setEnabled(True) - else: - self.comp_profile_btn.setEnabled(False) - - if section is None or section == "initrec_person": - if self.STATE.ids.un_id is not None: - self.initrec_person_btn.setEnabled(True) - else: - self.initrec_person_btn.setEnabled(False) - - if section is None or section == "banners": - self._clear_info_banners() - if self.STATE.ids.un_id is None: - self._add_info_banner( - text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", - button_text="", - ) - - def _sync_GUI_to_state(self) -> None: - auto_form_state = self.auto_form.get_state() - self.STATE.ids = auto_form_state.ids - # self.STATE.un_id = auto_form_state.rec_id # TODO remove - - def load_state( - self, - new_state: Page_InitRecCompany_State, - ) -> None: - set_page_state(self.STATE, new_state) - - self._sync_state_to_GUI(None) - - def get_state(self) -> Page_InitRecCompany_State: - self._sync_GUI_to_state() - - return self.STATE - - def lock(self) -> None: - self.auto_form.lock_form() - self.STATE.locked = True - - def unlock(self) -> None: - self.auto_form.unlock_form() - self.STATE.locked = False + widget: QWidget, + ): + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) # Keine zusätzlichen Ränder + layout.addWidget(widget) + self.setContentLayout(layout) -class Page_InitRecPerson(QWidget): +class Page_InitRec(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) def __init__( self, - state: Page_InitRecPerson_State, + state: Page_InitRec_State, ): super().__init__() self.STATE = state @@ -3292,7 +3128,12 @@ class Page_InitRecPerson(QWidget): back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) - title = QLabel("Grunderfassung Individualperson") + if self.STATE.record_type is RecordingType.COMPANY: + title = QLabel("Grunderfassung Unternehmen") + elif self.STATE.record_type is RecordingType.PERSON: + title = QLabel("Grunderfassung Individualperson") + else: + raise RuntimeError(f"Unspecified recording type {self.STATE.record_type}") title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) @@ -3309,6 +3150,25 @@ class Page_InitRecPerson(QWidget): self.info_banners_layout.setSpacing(6) header_layout.addWidget(self.info_banners_container) + # --- BUTTONS --- + self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True) + modify_btn = self.edit_buttons.modify_button + assert modify_btn + self.modify_btn = modify_btn + reset_btn = self.edit_buttons.reset_btn + assert reset_btn + self.reset_btn = reset_btn + self.save_btn = self.edit_buttons.save_btn + self.delete_btn = self.edit_buttons.delete_btn + + self.modify_btn.modify_activated.connect(self.unlock) + self.modify_btn.modify_deactivated.connect(self.lock) + self.save_btn.clicked.connect(self.save_data) + self.reset_btn.clicked.connect(self._reset) + self.delete_btn.clicked.connect(self._delete_data) + header_layout.addSpacing(5) + header_layout.addWidget(self.edit_buttons) + # --- MAIN CONTENT --- container = QWidget() # SCROLL AREA @@ -3326,27 +3186,38 @@ class Page_InitRecPerson(QWidget): container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) - # --- AUTO FORM LAYOUT --- + # --- AUTO FORM LAYOUT 1 --- + # now we try to render the layout within the collapsable box container_layout.addSpacing(20) - self.AUTO_FORM_CFG = CONFIG_GRUNDERFASSUNG_PERSONEN - auto_form_state = InitRecForm_State( + + self.initrec_form_company: InitRecForm | None = None + if self.STATE.record_type is RecordingType.COMPANY: + # add company + pass + + box1 = CollapsibleBox("Angaben zur Person") + initrec_form_state_person = Page_InitRec_Form_State( session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, + cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, ids=self.STATE.ids, - ent_type=EntityType.PERSON, + recording_type=RecordingType.PERSON, locked=False, ) - self.auto_form = InitRecForm(auto_form_state) - container_layout.addWidget(self.auto_form) - self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) - self.auto_form.update_triggered.connect(self._auto_form_updated) - - container_layout.addSpacing(15) + self.initrec_form_person = InitRecForm(initrec_form_state_person) + self.STATE.child_modules.append(self.initrec_form_person) + box1.setContentWidget(self.initrec_form_person) + container_layout.addWidget(box1) + # container_layout.addSpacing(15) + self.initrec_form_person.update_triggered.connect( + lambda: self.update_triggered.emit() + ) + self.initrec_form_person.update_triggered.connect(self._auto_form_updated) # --- CUSTOM LOGIC --- # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' person_location = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" + self.initrec_form_person.widget_registry, + f"Stammdaten{COLUMN_SEP}aufenthaltsort", ) assert len(person_location) == 1 self.person_location = cast(QComboBox, person_location[0]["widget"]) @@ -3354,7 +3225,7 @@ class Page_InitRecPerson(QWidget): self.person_location.currentIndexChanged.connect(self._custom_county_selection) selection_county = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + self.initrec_form_person.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" ) assert len(selection_county) == 1 self.selection_county = cast(QComboBox, selection_county[0]["widget"]) @@ -3362,6 +3233,9 @@ class Page_InitRecPerson(QWidget): self.selection_county.setProperty("styleClass", "stempel") self.selection_county.setEnabled(False) + container_layout.addStretch() + + # ** custom logic def _custom_county_selection(self, idx: int) -> None: value = self.person_location.itemData(idx) if value == "Inland": @@ -3378,6 +3252,7 @@ class Page_InitRecPerson(QWidget): self.selection_county.style().polish(self.selection_county) self.selection_county.update() + # ** GUI behaviour def _clear_info_banners(self) -> None: while self.info_banners: banner = self.info_banners[0] @@ -3403,29 +3278,109 @@ class Page_InitRecPerson(QWidget): self.info_banners.append(banner) self.info_banners_layout.addWidget(banner) + def _disable_save(self) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None + self.save_btn.setEnabled(False) + self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) + + def _enable_save( + self, + timeout: int = 3000, + ) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None + QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) + + def _activate_delete(self) -> None: + if self._deletion_valid(): + self.delete_btn.setEnabled(True) + else: + self.delete_btn.setEnabled(False) + + def _deletion_valid(self) -> bool: + valid_id_states = (form.relevant_ids_set() for form in self.STATE.child_modules) + return all(valid_id_states) + + def _delete_data(self) -> None: + assert self._deletion_valid(), "deletion initialised despite IDs are not properly set" + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + if not confirm: + return + + self.STATE.geloescht = True + self.save_data() + self.update_triggered.emit() + def _auto_form_updated(self) -> None: self._sync_GUI_to_state() self._sync_state_to_GUI("banners") # TODO saving logic to connect company and person if needed - def reset_form(self) -> None: - self.auto_form.reset_form() + def _reset(self) -> None: + # TODO change to make these calls implicit in child modules via + # TODO staes + for form in self.STATE.child_modules: + form.reset_form() + self.STATE.ids = NewEntityIds() + self.STATE.geloescht = False + + def _load_database( + self, + ids: NewEntityIds, + ) -> None: + logger_initrecform.info("[Page -- InitRec] Load data method...") + logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) + self._reset() + # TODO add loading logic + initrec: InitRec = ... + + self._load_from_pydantic(initrec, sync_to_GUI=False) + + def _load_from_pydantic( + self, + data: InitRec, + update_only: bool = False, + sync_to_GUI: bool = True, + ) -> None: + # TODO add logic to update internal state + + if update_only: + ... + else: + ... + + if sync_to_GUI: + self._sync_state_to_GUI(None) def _sync_state_to_GUI( self, - section: Literal["auto_form", "banners"] | None, + section: Literal["content", "banners", "locking"] | None, ) -> None: - if section is None or section == "auto_form": - auto_form_state = InitRecForm_State( + logger_page_initrec.debug( + "[Page -- InitRec] Syncing state to GUI: State...\n%s", pformat(self.STATE) + ) + + if section is None or section == "content": + if not self.STATE.geloescht: + self._load_database(self.STATE.ids) + else: + self._reset() + + initrec_form_state_person = Page_InitRec_Form_State( session=self.STATE.session, - cfg=self.AUTO_FORM_CFG, + cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, ids=self.STATE.ids, - ent_type=EntityType.PERSON, - # rec_id=self.STATE.pers_id, # TODO remove - form_data=None, - locked=self.STATE.locked, + recording_type=RecordingType.PERSON, + locked=False, ) - self.auto_form.load_state(auto_form_state) + self.initrec_form_person.load_state(initrec_form_state_person) if section is None or section == "banners": self._clear_info_banners() @@ -3435,30 +3390,165 @@ class Page_InitRecPerson(QWidget): button_text="", ) + if section is None or section == "locking": + if self.STATE.locked: + self.edit_buttons.set_modify_state(False, set_delete_btn=True) + for form in self.STATE.child_modules: + form.lock() + else: + self.edit_buttons.set_modify_state(True, set_delete_btn=False) + for form in self.STATE.child_modules: + form.unlock() + def _sync_GUI_to_state(self) -> None: - auto_form_state = self.auto_form.get_state() - self.STATE.ids = auto_form_state.ids - # self.STATE.ids.pers_id = auto_form_state.rec_id # TODO remove + for form in self.STATE.child_modules: + # ?? is this the same reference to use a similar pattern as + # ?? the consulting page (initialise once with the same state + # ?? as reference which are then automatically updated) + form_state = form.get_state() + self.STATE.ids.update(form_state.ids, ignore_none=True) + self.STATE.form_data[form_state.recording_type] = form_state.form_data + self.STATE.pydantic_models[form_state.recording_type] = ( + form_state.cfg.pydantic_model + ) def load_state( self, - new_state: Page_InitRecPerson_State, + new_state: Page_InitRec_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI(None) - def get_state(self) -> Page_InitRecPerson_State: + def get_state(self) -> Page_InitRec_State: self._sync_GUI_to_state() return self.STATE + def validate(self) -> list[str]: + errors: list[str] = [] + for m in self.STATE.child_modules: + errors.extend(m.validate()) + + return errors + def lock(self) -> None: - self.auto_form.lock_form() + logger_page_initrec.debug("[Page -- InitRec] Locking...") self.STATE.locked = True + self._sync_state_to_GUI("locking") def unlock(self) -> None: - self.auto_form.unlock_form() + logger_page_initrec.debug("[Page -- InitRec] Unlocking...") self.STATE.locked = False + self._sync_state_to_GUI("locking") + + def save_data(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Save button clicked...") + + if self.STATE.ids.empty() and self.STATE.geloescht: + logger_page_initrec.debug( + "[Page -- InitRec] This was never saved in the database " + "and is marked for deletion - ignore." + ) + return + elif not self.STATE.ids.empty() and self.STATE.geloescht: + logger_page_initrec.debug( + "[Page -- InitRec] This was saved in the database " + "and is marked for deletion - fast path." + ) + deleted_datetime = datetime.datetime.now(datetime.UTC) + assert self.STATE.form_data, "cannot delete entry with uninitialised form data" + to_db = InitRec( + ids=self.STATE.ids, + geloescht=deleted_datetime, + db_data={}, + ) + returned = self.cfg.to_db(to_db) + self._load_from_pydantic(returned) + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Löschen erfolgreich", + "Der Eintrag wurde erfolgreich gelöscht", + ) + msg_box.exec() + + return + + self._disable_save() + errors = self.validate() + + if errors: + logger_page_initrec.error( + "[Page -- InitRec] Error during GUI validation phase:\n%s", pformat(errors) + ) + GUI_pydantic_validation_error_handling(errors) + self._enable_save() + return + + logger_page_initrec.info("[Page -- InitRec] Success! All data is valid") + logger_page_initrec.info("[Page -- InitRec] Get form data call...") + state = self.get_state() + form_data = state.form_data + assert form_data, "form data not set" + + logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data)) + + try: + logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...") + validated_data = self.cfg.pydantic_model(**form_data) + except ValidationError as e: + logger_page_initrec.error( + "[Page -- InitRec] Error during Pydantic validation phase:\n%s", + pformat(e.errors()), + ) + pydantic_validation_error_handling(e) + else: + if DEBUG_NO_DATABASE: + return + # !! this code is only called if the 'try' block was successful + # save data to database + db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + logger_page_initrec.debug( + "[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s", + pformat(db_data), + ) + deleted_datetime: datetime.datetime | None = None + if self.STATE.geloescht: + deleted_datetime = datetime.datetime.now(datetime.UTC) + try: + # TODO change to result pattern + to_db = InitRec( + ids=self.STATE.ids, + # rec_id=self.STATE.rec_id, + geloescht=deleted_datetime, + db_data=db_data, + ) + returned = self.cfg.to_db(to_db) + + self._load_from_pydantic(returned) + self.update_triggered.emit() + except Exception as err: + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Speichern fehlgeschlagen", + ( + "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=str(err), + ) + msg_box.exec() + else: + logger_page_initrec.info("[Page -- InitRec] Data saved successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Speichern erfolgreich", + "Die Daten wurden erfolgreich gespeichert", + ) + msg_box.exec() + + finally: + # always re-enable save, even if error occurred + self._enable_save() class Page_CompanyProfile(QWidget): @@ -3838,6 +3928,8 @@ class Page_CompanyProfile(QWidget): return self._enable_add_btns() + # TODO change to correct backend function + raise RuntimeError("TODOs to implement") init_rec = backend.initrec_company_get_initial_recording(self.STATE.ids.un_id) ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) assert ma_id is not None, "no ma_id obtained" @@ -4521,12 +4613,14 @@ class Page_Consulting(QWidget): cons_id: ConsId | None, ) -> None: # load data model from database - logger_auto_form.info("[Consulting Page] Load data method...") - logger_auto_form.debug("[Consulting Page] Lookup ID: %s", cons_id) + logger_page_consulting.info("[Consulting Page] Load data method...") + logger_page_consulting.debug("[Consulting Page] Lookup ID: %s", cons_id) self._reset() if cons_id is None: - logger_auto_form.debug("[Consulting Page] Loading: Lookup ID NULL. Do nothing!") + logger_page_consulting.debug( + "[Consulting Page] Loading: Lookup ID NULL. Do nothing!" + ) return res = backend.page_consulting_from_db(cons_id) @@ -4611,7 +4705,7 @@ class Page_Consulting(QWidget): def _delete_entry(self) -> None: assert self.STATE.vorgang_id is not None and self.STATE.geloescht - logger_auto_form.debug( + logger_page_consulting.debug( "[Consulting Page] This was saved in the database " "and is marked for deletion - fast path." ) @@ -4652,7 +4746,7 @@ class Page_Consulting(QWidget): def save_data(self) -> None: logger_page_consulting.debug("[Consulting Page] Call saving data...") if self.STATE.vorgang_id is None and self.STATE.geloescht: - logger_auto_form.debug( + logger_page_consulting.debug( "[Consulting Page] This was never saved in the database " "and is marked for deletion - ignore." ) @@ -4721,7 +4815,7 @@ class Page_Consulting(QWidget): ) except ValidationError as e: - logger_auto_form.error( + logger_page_consulting.error( "[Consulting Page] Error during Pydantic validation phase:\n%s", pformat(e.errors()), ) @@ -5586,6 +5680,9 @@ class InfoBanner(QFrame): self.button.clicked.connect(self.btn_clicked.emit) +# // test components + + #################################################################### @@ -5830,38 +5927,53 @@ class MainWindow(QMainWindow): self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' - new_initrec_select_state = Page_NewInitRec_State(session=self.STATE.session) - self.new_initrec_select = Page_NewInitRec(new_initrec_select_state) - self.new_initrec_select.back_requested.connect(self.show_main_page) - self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) - self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) - self.new_initrec_select.consulting_requested.connect(self.show_page_consulting) - self.stack.addWidget(self.new_initrec_select) + new_initrec_select_state = Page_NewEntry_State(session=self.STATE.session) + self.new_entry_select = Page_NewEntry(new_initrec_select_state) + self.new_entry_select.back_requested.connect(self.show_main_page) + # self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) + # self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) + self.new_entry_select.consulting_requested.connect(self.show_page_consulting) + self.new_entry_select.new_initrec_requested.connect(self.show_page_newinitrec) + self.stack.addWidget(self.new_entry_select) + + # SITE: 'Grunderfassung' + newinitrec_state = Page_InitRec_State( + session=session, + ids=NewEntityIds(), + record_type=RecordingType.COMPANY, + locked=False, + ) + self.newinitrec = Page_InitRec(newinitrec_state) + self.newinitrec.back_main_requested.connect(self.show_main_page) + self.newinitrec.back_requested.connect(self.simple_go_back) + # self.newinitrec.update_main_page.connect(self.update_grid) + self.stack.addWidget(self.newinitrec) + # SITE: 'Grunderfassung Unternehmen' - ids = EntityIds(type=EntityType.COMPANY) - initrec_company_state = Page_InitRecCompany_State( - session=self.STATE.session, ids=ids, locked=False - ) - self.initrec_company = Page_InitRecCompany(initrec_company_state) - self.initrec_company.back_main_requested.connect(self.show_main_page) - self.initrec_company.back_requested.connect(self.simple_go_back) - self.initrec_company.update_main_page.connect(self.update_grid) - self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person) - self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) - self.stack.addWidget(self.initrec_company) + # ids = EntityIds(type=RecordingType.COMPANY) + # initrec_company_state = Page_InitRecCompany_State( + # session=self.STATE.session, ids=ids, locked=False + # ) + # self.initrec_company = Page_InitRecCompany(initrec_company_state) + # self.initrec_company.back_main_requested.connect(self.show_main_page) + # self.initrec_company.back_requested.connect(self.simple_go_back) + # self.initrec_company.update_main_page.connect(self.update_grid) + # self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person) + # self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) + # self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' - ids = EntityIds(type=EntityType.PERSON) - initrec_person_state = Page_InitRecPerson_State( - session=self.STATE.session, ids=ids, locked=False - ) - self.initrec_person = Page_InitRecPerson(initrec_person_state) - self.initrec_person.back_main_requested.connect(self.show_main_page) - self.initrec_person.back_requested.connect(self.simple_go_back) - self.initrec_person.update_triggered.connect(self.update_grid) - self.stack.addWidget(self.initrec_person) + # ids = EntityIds(type=RecordingType.PERSON) + # initrec_person_state = Page_InitRecPerson_State( + # session=self.STATE.session, ids=ids, locked=False + # ) + # self.initrec_person = Page_InitRecPerson(initrec_person_state) + # self.initrec_person.back_main_requested.connect(self.show_main_page) + # self.initrec_person.back_requested.connect(self.simple_go_back) + # self.initrec_person.update_triggered.connect(self.update_grid) + # self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' comp_profile_state = Page_CompanyProfile_State( - session=self.STATE.session, ids=EntityIds() + session=self.STATE.session, ids=NewEntityIds() ) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) @@ -5891,16 +6003,17 @@ class MainWindow(QMainWindow): vert_layout.addWidget(new_btn) if DEBUG: - update_btn = QPushButton("UPDATE") - update_btn.setFixedWidth(100) - update_btn.setFixedHeight(40) - update_btn.clicked.connect(self.update_grid) - clear_btn = QPushButton("CLEAR") - clear_btn.setFixedWidth(100) - clear_btn.setFixedHeight(40) - clear_btn.clicked.connect(self._clear_layout) - vert_layout.addWidget(update_btn) - vert_layout.addWidget(clear_btn) + # TODO reactivate + # update_btn = QPushButton("UPDATE") + # update_btn.setFixedWidth(100) + # update_btn.setFixedHeight(40) + # update_btn.clicked.connect(self.update_grid) + # clear_btn = QPushButton("CLEAR") + # clear_btn.setFixedWidth(100) + # clear_btn.setFixedHeight(40) + # clear_btn.clicked.connect(self._clear_layout) + # vert_layout.addWidget(update_btn) + # vert_layout.addWidget(clear_btn) separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) separator.setFrameShadow(QFrame.Shadow.Sunken) @@ -5961,68 +6074,70 @@ class MainWindow(QMainWindow): for col_idx, title in enumerate(headers): self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) - self.update_grid() + # self.update_grid() return main_widget def _clear_layout(self) -> None: clear_layout(self.grid) - def update_grid(self) -> None: - clear_layout(self.grid) + # def update_grid(self) -> None: + # clear_layout(self.grid) - data = backend.main_page_get_entries() + # data = backend.main_page_get_entries() - for entry in data: - self.add_row_to_grid(entry) + # for entry in data: + # self.add_row_to_grid(entry) - def add_row_to_grid( - self, - entry: types.MainPageEntry, - ): - row = self.current_row + # TODO rework + # def add_row_to_grid( + # self, + # entry: types.MainPageEntry, + # ): + # row = self.current_row - # NAME - cell_name = MainPage_ClickableCell(entry.display_name, entry) - cell_name.clicked.connect(self.goto_initial_recording) - self.grid.addWidget(cell_name, row, 0) - # DATE - cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) - cell_date = MainPage_ClickableCell(cell_date_name, entry) - cell_date.clicked.connect(self.goto_initial_recording) - self.grid.addWidget(cell_date, row, 4) + # # NAME + # cell_name = MainPage_ClickableCell(entry.display_name, entry) + # cell_name.clicked.connect(self.goto_initial_recording) + # self.grid.addWidget(cell_name, row, 0) + # # DATE + # cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) + # cell_date = MainPage_ClickableCell(cell_date_name, entry) + # cell_date.clicked.connect(self.goto_initial_recording) + # self.grid.addWidget(cell_date, row, 4) - self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen + # self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen - def goto_initial_recording( - self, - data: types.MainPageEntry, - ): - target: QWidget - if data.type is types.EntityType.COMPANY: - ids = EntityIds(un_id=data.rec_id, type=EntityType.COMPANY) - new_state = Page_InitRecCompany_State( - session=self.STATE.session, - ids=ids, - # un_id=data.rec_id, # TODO remove - locked=True, - ) - self.initrec_company.load_state(new_state) - target = self.initrec_company - elif data.type is types.EntityType.PERSON: - ids = EntityIds(pers_id=data.rec_id, type=EntityType.PERSON) - new_state = Page_InitRecPerson_State( - session=self.STATE.session, - ids=ids, - # pers_id=data.rec_id, # TODO remove - locked=True, - ) - self.initrec_person.load_state(new_state) - target = self.initrec_person - else: - raise TypeError(f"Unknown init rec type: {data.type}") + # TODO rework + # def goto_initial_recording( + # self, + # data: types.MainPageEntry, + # ): + # target: QWidget + # if data.type is types.RecordingType.COMPANY: + # ids = EntityIds(un_id=data.rec_id, type=RecordingType.COMPANY) + # new_state = Page_InitRecCompany_State( + # session=self.STATE.session, + # ids=ids, + # # un_id=data.rec_id, # TODO remove + # locked=True, + # ) + # self.initrec_company.load_state(new_state) + # target = self.initrec_company + # elif data.type is types.RecordingType.PERSON: + # ids = EntityIds(pers_id=data.rec_id, type=RecordingType.PERSON) + # new_state = Page_InitRecPerson_State( + # session=self.STATE.session, + # ids=ids, + # # pers_id=data.rec_id, # TODO remove + # locked=True, + # ) + # self.initrec_person.load_state(new_state) + # target = self.initrec_person + # else: + # raise TypeError(f"Unknown init rec type: {data.type}") - self._navigate_to_target(target) + # self._navigate_to_target(target) def _navigate_to_target( self, @@ -6047,21 +6162,28 @@ class MainWindow(QMainWindow): self._navigate_to_target(self.main_page) def show_new_entry_select(self): - self._navigate_to_target(self.new_initrec_select) + self._navigate_to_target(self.new_entry_select) - def show_page_initrec_company( + def show_page_newinitrec( self, - new_state: Page_InitRecCompany_State, + new_state: Page_InitRec_State, ): - self.initrec_company.load_state(new_state) - self._navigate_to_target(self.initrec_company) + self.newinitrec.load_state(new_state) + self._navigate_to_target(self.newinitrec) - def show_page_initrec_person( - self, - new_state: Page_InitRecPerson_State, - ): - self.initrec_person.load_state(new_state) - self._navigate_to_target(self.initrec_person) + # def show_page_initrec_company( + # self, + # new_state: Page_InitRecCompany_State, + # ): + # self.initrec_company.load_state(new_state) + # self._navigate_to_target(self.initrec_company) + + # def show_page_initrec_person( + # self, + # new_state: Page_InitRecPerson_State, + # ): + # self.initrec_person.load_state(new_state) + # self._navigate_to_target(self.initrec_person) def show_page_company_profile( self, @@ -6135,8 +6257,10 @@ if TYPE_CHECKING: _m1: type[Module] = Page_Consulting _m2: type[Module] = Page_Consulting_Table _m3: type[Module] = Page_Consulting_ConsultingSession - _w1: type[WrapperModule] = Page_InitRecCompany - _w2: type[WrapperModule] = Page_InitRecPerson + _m4: type[Module] = Page_InitRec + _m5: type[Module] = InitRecForm + # _w1: type[WrapperModule] = Page_InitRecCompany + # _w2: type[WrapperModule] = Page_InitRecPerson _f1: type[CustomForm] = DynamicListWidget _f2: type[CustomForm] = DynamicDropdownWidgetNumeric _f3: type[CustomForm] = DynamicDropdownWidgetOption diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index db7b996..d011a09 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -30,18 +30,27 @@ logger_base.setLevel(logging.DEBUG) # ** GUI logger_gui = logger_base.getChild("gui") logger_gui.setLevel(logging.DEBUG) + logger_search_widget = logger_gui.getChild("search_widget") -logger_search_widget.setLevel(logging.DEBUG) +logger_search_widget.setLevel(logging.WARNING) + logger_get_data = logger_gui.getChild("get_data") -logger_get_data.setLevel(logging.DEBUG) -logger_auto_form = logger_gui.getChild("get_data_auto_form") -logger_auto_form.setLevel(logging.DEBUG) +logger_get_data.setLevel(logging.WARNING) logger_custom_fields = logger_gui.getChild("custom_fields") -logger_custom_fields.setLevel(logging.DEBUG) +logger_custom_fields.setLevel(logging.WARNING) logger_page_consulting = logger_gui.getChild("page_consulting") -logger_page_consulting.setLevel(logging.DEBUG) +logger_page_consulting.setLevel(logging.WARNING) + +logger_page_initrec = logger_gui.getChild("page_initrec") +logger_page_initrec.setLevel(logging.DEBUG) + +logger_initrecform = logger_gui.getChild("initrecform") +logger_initrecform.setLevel(logging.DEBUG) + +logger_prototyping = logger_gui.getChild("prototyping") +logger_prototyping.setLevel(logging.DEBUG) # ** Backend logger_back = logger_base.getChild("backend") diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 9d4fc34..c53072f 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -1,8 +1,10 @@ from __future__ import annotations +import copy import dataclasses as dc import datetime import enum +from collections.abc import Generator from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: @@ -12,13 +14,16 @@ if TYPE_CHECKING: UserId: TypeAlias = int RecId: TypeAlias = int ConsId: TypeAlias = int +IntLinkId: TypeAlias = int ExtMaId: TypeAlias = int ExtAnId: TypeAlias = int PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] -class EntityType(enum.IntEnum): +# TODO rename: these are not entity types or entities but different forms of +# initial recordings +class RecordingType(enum.IntEnum): COMPANY = enum.auto() PERSON = enum.auto() @@ -29,55 +34,124 @@ class ConsultingType(enum.StrEnum): @dc.dataclass(slots=True, kw_only=True) -class EntityIds: - un_id: RecId | None = None +class NewEntityIds: + un_id: ExtMaId | None = None + an_id: ExtAnId | None = None pers_id: RecId | None = None - type: dc.InitVar[EntityType | None] = None - _ent_type: EntityType | None = dc.field(init=False) + link_id: IntLinkId | None = None - def __post_init__(self, type: EntityType | None) -> None: - self._ent_type = type - - @property - def ent_type(self) -> EntityType | None: - return self._ent_type - - def any(self) -> bool: - return self.un_id is not None or self.pers_id is not None - - def all(self) -> bool: - return self.un_id is not None and self.pers_id is not None - - def valid(self) -> bool: - if self.ent_type is None: - raise TypeError("Initrec type not defined. Can not determine valid state.") - elif self.ent_type is EntityType.COMPANY: - return self.all() - elif self.ent_type is EntityType.PERSON: - return self.any() - else: - raise RuntimeError( - f"Unknown initrec type {self.ent_type}. This is not supported." - ) - - def set_id( + def update( self, - id_: RecId | None, - ent_type: EntityType, + ids: NewEntityIds, + ignore_none: bool = False, + deep: bool = False, ) -> None: - if ent_type is EntityType.COMPANY: - self.un_id = id_ - elif ent_type is EntityType.PERSON: - self.pers_id = id_ + if not isinstance(ids, NewEntityIds): + raise TypeError("New IDs must be have the same type as this object") - def get_id( + for field in dc.fields(ids): + new_value = getattr(ids, field.name) + + if ignore_none and new_value is None: + continue + + if deep and new_value is not None: + new_value = copy.deepcopy(new_value) + + setattr(self, field.name, new_value) + + def _get_relevant_fields( self, - ent_type: EntityType, - ) -> RecId | None: - if ent_type is EntityType.COMPANY: - return self.un_id - elif ent_type is EntityType.PERSON: - return self.pers_id + recording_type: RecordingType | None = None, + ) -> Generator[int | None, None, None]: + if recording_type is None: + relevant_fields = (getattr(self, field.name) for field in dc.fields(self)) + elif recording_type is RecordingType.COMPANY: + RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"} + relevant_fields = ( + getattr(self, field.name) + for field in dc.fields(self) + if field.name in RELEVANT_ATTRIBUTES + ) + elif recording_type is RecordingType.PERSON: + RELEVANT_ATTRIBUTES = {"pers_id"} + relevant_fields = ( + getattr(self, field.name) + for field in dc.fields(self) + if field.name in RELEVANT_ATTRIBUTES + ) + else: + raise RuntimeError(f"Unspecified recording type {recording_type}") + + return relevant_fields + + def empty( + self, + recording_type: RecordingType | None = None, + ) -> bool: + relevant_fields = self._get_relevant_fields(recording_type=recording_type) + + return not any(relevant_fields) + + def valid( + self, + recording_type: RecordingType | None = None, + ) -> bool: + relevant_fields = self._get_relevant_fields(recording_type=recording_type) + + return all(relevant_fields) + + +# @dc.dataclass(slots=True, kw_only=True) +# class EntityIds: +# un_id: RecId | None = None +# pers_id: RecId | None = None +# type: dc.InitVar[RecordingType | None] = None +# _rec_type: RecordingType | None = dc.field(init=False) + +# def __post_init__(self, type: RecordingType | None) -> None: +# self._rec_type = type + +# @property +# def rec_type(self) -> RecordingType | None: +# return self._rec_type + +# def any(self) -> bool: +# return self.un_id is not None or self.pers_id is not None + +# def all(self) -> bool: +# return self.un_id is not None and self.pers_id is not None + +# def valid(self) -> bool: +# if self.rec_type is None: +# raise TypeError("Initrec type not defined. Can not determine valid state.") +# elif self.rec_type is RecordingType.COMPANY: +# return self.all() +# elif self.rec_type is RecordingType.PERSON: +# return self.any() +# else: +# raise RuntimeError( +# f"Unknown initrec type {self.rec_type}. This is not supported." +# ) + +# def set_id( +# self, +# id_: RecId | None, +# ent_type: RecordingType, +# ) -> None: +# if ent_type is RecordingType.COMPANY: +# self.un_id = id_ +# elif ent_type is RecordingType.PERSON: +# self.pers_id = id_ + +# def get_id( +# self, +# ent_type: RecordingType, +# ) -> RecId | None: +# if ent_type is RecordingType.COMPANY: +# return self.un_id +# elif ent_type is RecordingType.PERSON: +# return self.pers_id # // database interaction data structures @@ -140,7 +214,7 @@ class MainPageEntry: rec_id: RecId display_name: str Metadaten_aktualisierung: datetime.datetime - type: EntityType + type: RecordingType @dc.dataclass(slots=True) -- 2.34.1 From acfc4a824ae7044306b98b86e597c7d5111470b8 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 24 Jul 2026 17:10:17 +0200 Subject: [PATCH 55/74] continue work on new data model --- ...8000d15faf03_major_change_in_data_model.py | 317 ++++++++++++++ prototypes/dual_dict.py | 54 +++ prototypes/tests.py | 40 +- src/wce_crm/backend/backend.py | 205 ++++++++- src/wce_crm/data_models.py | 112 ++++- src/wce_crm/db.py | 7 +- src/wce_crm/form_defs.py | 72 ++-- src/wce_crm/gui.py | 390 +++++++++++++----- src/wce_crm/types.py | 49 ++- 9 files changed, 1054 insertions(+), 192 deletions(-) create mode 100644 alembic/versions/8000d15faf03_major_change_in_data_model.py create mode 100644 prototypes/dual_dict.py diff --git a/alembic/versions/8000d15faf03_major_change_in_data_model.py b/alembic/versions/8000d15faf03_major_change_in_data_model.py new file mode 100644 index 0000000..5242e0a --- /dev/null +++ b/alembic/versions/8000d15faf03_major_change_in_data_model.py @@ -0,0 +1,317 @@ +"""major change in data model + +Revision ID: 8000d15faf03 +Revises: 665259cdfdc0 +Create Date: 2026-07-24 15:13:57.443684 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "8000d15faf03" +down_revision: Union[str, Sequence[str], None] = "665259cdfdc0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("grunderfassung_personen") + op.drop_table("beratung_vorgang") + op.drop_table("grunderfassung_unternehmen") + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_index( + batch_op.f("uq_aktive_zuordnung"), sqlite_where=sa.text("gueltig_bis IS NULL") + ) + + op.drop_table("zuordnung_personen_unternehmen") + op.drop_table("beratung_einzelberatung") + + op.create_table( + "Grunderfassung_Personen", + sa.Column("pers_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.Date(), nullable=True), + sa.Column("Arbeitserfahrung", sa.Text(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("HoehereBildung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.Text(), nullable=True), + sa.Column("Schulbildung", sa.Text(), nullable=True), + sa.Column("Sprachkenntnisse", sa.Text(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.Text(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.Text(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.Text(), nullable=True), + sa.Column("Stammdaten__land", sa.Text(), nullable=True), + sa.Column("Stammdaten__email", sa.Text(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.Text(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.Date(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.Text(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__name", sa.Text(), nullable=True), + sa.Column("Stammdaten__ort", sa.Text(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.Boolean(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.Text(), nullable=True), + sa.Column("Stammdaten__strasse", sa.Text(), nullable=True), + sa.Column("Stammdaten__titel", sa.Text(), nullable=True), + sa.Column("Stammdaten__vorname", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + op.create_table( + "Unternehmen", + sa.Column("un_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "Beratung_Vorgang", + sa.Column("vorgang_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=True), + sa.Column("pers_id", sa.Integer(), nullable=True), + sa.Column("titel", sa.Text(), nullable=False), + sa.Column("beratungs_typ", sa.String(length=20), nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["Grunderfassung_Personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["Unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "Personen_Unternehmen_Zuordnung", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=False), + sa.Column("an_id", sa.Integer(), nullable=False), + sa.Column("pers_id", sa.Integer(), nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["Grunderfassung_Personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["Unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.create_index( + "uq_aktive_zuordnung", + ["un_id", "pers_id"], + unique=True, + sqlite_where=sa.text("geloescht IS NULL"), + ) + + op.create_table( + "Beratung_Einzelberatung", + sa.Column("beratung_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("vorgang_id", sa.Integer(), nullable=False), + sa.Column("nutzer_id", sa.Integer(), nullable=False), + sa.Column("nutzer_name", sa.String(length=20), nullable=False), + sa.Column("zeitstempel", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("ansprechpartner", sa.Text(), nullable=False), + sa.Column("kommunikationsweg", sa.Text(), nullable=False), + sa.Column("thema_crm_matrix", sa.Text(), nullable=True), + sa.Column("anmerkungen", sa.Text(), nullable=True), + sa.Column("rueckmeldung", sa.Text(), nullable=True), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["vorgang_id"], + ["Beratung_Vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("Beratung_Einzelberatung") + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.drop_index("uq_aktive_zuordnung", sqlite_where=sa.text("geloescht IS NULL")) + + op.drop_table("Personen_Unternehmen_Zuordnung") + op.drop_table("Beratung_Vorgang") + op.drop_table("Unternehmen") + op.drop_table("Grunderfassung_Personen") + + op.create_table( + "beratung_einzelberatung", + sa.Column("beratung_id", sa.INTEGER(), nullable=False), + sa.Column("vorgang_id", sa.INTEGER(), nullable=False), + sa.Column("nutzer_id", sa.INTEGER(), nullable=False), + sa.Column("nutzer_name", sa.VARCHAR(length=20), nullable=False), + sa.Column("zeitstempel", sa.DATETIME(), nullable=False), + sa.Column("ansprechpartner", sa.TEXT(), nullable=False), + sa.Column("kommunikationsweg", sa.TEXT(), nullable=False), + sa.Column("thema_crm_matrix", sa.TEXT(), nullable=True), + sa.Column("anmerkungen", sa.TEXT(), nullable=True), + sa.Column("rueckmeldung", sa.TEXT(), nullable=True), + sa.Column("erstellt", sa.DATETIME(), nullable=False), + sa.Column("aktualisiert", sa.DATETIME(), nullable=False), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["vorgang_id"], + ["beratung_vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + op.create_table( + "zuordnung_personen_unternehmen", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("un_id", sa.INTEGER(), nullable=False), + sa.Column("pers_id", sa.INTEGER(), nullable=False), + sa.Column("gueltig_ab", sa.DATETIME(), nullable=False), + sa.Column("gueltig_bis", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("uq_aktive_zuordnung"), + ["un_id", "pers_id"], + unique=1, + sqlite_where=sa.text("gueltig_bis IS NULL"), + ) + + op.create_table( + "grunderfassung_unternehmen", + sa.Column("un_id", sa.INTEGER(), nullable=False), + sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__person_suche", sa.INTEGER(), nullable=True), + sa.Column("Partnersuche__un_suche", sa.INTEGER(), nullable=True), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "beratung_vorgang", + sa.Column("vorgang_id", sa.INTEGER(), nullable=False), + sa.Column("un_id", sa.INTEGER(), nullable=True), + sa.Column("pers_id", sa.INTEGER(), nullable=True), + sa.Column("titel", sa.TEXT(), nullable=False), + sa.Column("beratungs_typ", sa.VARCHAR(length=20), nullable=False), + sa.Column("erstellt", sa.DATETIME(), nullable=False), + sa.Column("aktualisiert", sa.DATETIME(), nullable=False), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "grunderfassung_personen", + sa.Column("pers_id", sa.INTEGER(), nullable=False), + sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.DATE(), nullable=True), + sa.Column("Arbeitserfahrung", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), + sa.Column("HoehereBildung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.TEXT(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.TEXT(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.TEXT(), nullable=True), + sa.Column("Schulbildung", sa.TEXT(), nullable=True), + sa.Column("Sprachkenntnisse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.INTEGER(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__land", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__email", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.DATE(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__name", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__ort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.BOOLEAN(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__strasse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__titel", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__vorname", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.DATE(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.TEXT(), nullable=True), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + + # ### end Alembic commands ### diff --git a/prototypes/dual_dict.py b/prototypes/dual_dict.py new file mode 100644 index 0000000..17fc7b3 --- /dev/null +++ b/prototypes/dual_dict.py @@ -0,0 +1,54 @@ +# %% +from __future__ import annotations + +import copy +from typing import Any + +from dopt_basics.datastructures import DualDict + +from wce_crm.data_models import FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + +# %% +dd: DualDict[str, str] = DualDict(peter="test") + + +# %% +def rename_db_data( + db_data: dict[str, Any], + mapping: DualDict, +) -> dict[str, Any]: + new_db_data = copy.deepcopy(db_data) + + for k, v in db_data.items(): + if k in mapping: + new_db_data[mapping[k]] = v + del new_db_data[k] + + if k in mapping.inverted: + new_db_data[mapping.inverted[k]] = v + del new_db_data[k] + + return new_db_data + + +form_data = { + "Metadaten_erstellung": "t1", + "Metadaten_aktualisierung": "t2", + "Metadaten_nutzer": "t3", +} +db_data = rename_db_data(form_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) +# %% +db_data + +# %% +cvt_form_data = rename_db_data(db_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) +cvt_form_data +# %% +dd["test"] = "peter" +# %% +dd["test"] +# %% +dd.inverted["peter"] +# %% +dd +# %% diff --git a/prototypes/tests.py b/prototypes/tests.py index a2cb9a6..3644836 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -19,11 +19,11 @@ from PySide6.QtCore import QDate, Qt from wce_crm import constants, db from wce_crm.backend import backend -from wce_crm.types import NewEntityIds, RecordingType +from wce_crm.types import EntityIds, RecordingType # %% -raw_ent_ids = NewEntityIds() -db_ent_ids = NewEntityIds(un_id=3, an_id=2) +raw_ent_ids = EntityIds() +db_ent_ids = EntityIds(un_id=3, an_id=2) print(f"{raw_ent_ids=}\n{db_ent_ids=}") print(f"{raw_ent_ids.empty()=}") @@ -31,36 +31,36 @@ print(f"{raw_ent_ids.valid()=}") # %% raw_ent_ids.update(db_ent_ids) print(f"{raw_ent_ids=}") -db_ent_ids = NewEntityIds(un_id=None, an_id=2, link_id=1) +db_ent_ids = EntityIds(un_id=None, an_id=2, link_id=1) raw_ent_ids.update(db_ent_ids) print(f"{raw_ent_ids=}") print(f"{raw_ent_ids.empty()=}") print(f"{raw_ent_ids.valid()=}") -raw_ent_ids.update(NewEntityIds()) +raw_ent_ids.update(EntityIds()) print(f"{raw_ent_ids=}") print(f"{raw_ent_ids.empty()=}") print(f"{raw_ent_ids.valid()=}") # %% -raw_ent_ids.update(NewEntityIds(un_id=None, pers_id=2)) +raw_ent_ids.update(EntityIds(un_id=None, pers_id=2)) print(f"{raw_ent_ids=}") print(f"{raw_ent_ids.empty()=}") -print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") -print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") -raw_ent_ids.update(NewEntityIds(un_id=None, an_id=2, link_id=3, pers_id=2)) +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") +raw_ent_ids.update(EntityIds(un_id=None, an_id=2, link_id=3, pers_id=2)) print(f"{raw_ent_ids=}") -print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") -print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") -raw_ent_ids.update(NewEntityIds(un_id=2, pers_id=None, an_id=2, link_id=3)) +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") +raw_ent_ids.update(EntityIds(un_id=2, pers_id=None, an_id=2, link_id=3)) print(f"{raw_ent_ids=}") -print(f"{raw_ent_ids.empty(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.empty(RecordingType.PERSON)=}") -print(f"{raw_ent_ids.valid(RecordingType.COMPANY)=}") -print(f"{raw_ent_ids.valid(RecordingType.PERSON)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") # %% db_path = constants.Config.DB_PATH_MAIN diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 5ee9f50..f21406c 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -13,9 +13,12 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert from wce_crm import db from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( + FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN, + FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, InitRec, + Page_InitRec_Form_Data, ) from wce_crm.logging import logger_back as logger from wce_crm.types import ( @@ -24,14 +27,17 @@ from wce_crm.types import ( CompanyProfileConsultations, ConsultingType, ContactPersonInfo, + EntityIds, + EntityType, LinkingConsultationsEntryCompany, LinkingConsultationsEntryPerson, MainPageEntry, - NewEntityIds, RecordingType, ) if TYPE_CHECKING: + from dopt_basics.datastructures import DualDict + from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId @@ -348,14 +354,201 @@ def initrec_comp_contact_person_search_get_info( # return dict(row) +def _rename_db_data( + db_data: dict[str, Any], + mapping: DualDict, +) -> dict[str, Any]: + new_db_data = copy.deepcopy(db_data) + + for k, v in db_data.items(): + if k in mapping: + new_db_data[mapping[k]] = v + del new_db_data[k] + + if k in mapping.inverted: + new_db_data[mapping.inverted[k]] = v + del new_db_data[k] + + return new_db_data + + def page_initrec_to_db( data: InitRec, -) -> InitRec: ... +) -> InitRec: + + with db.ENGINE.begin() as conn: + # person + dump_data = _rename_db_data( + data.form_data.person, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + dump_data["geloescht"] = data.geloescht + + if data.ids.pers_id is None: + logger.debug("[InitRec -- backend] Insert...") + + stmt = sa.insert(db.t_grunderfassung_personen).returning( + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.aktualisiert, + db.t_grunderfassung_personen.c.geloescht, + ) + stmt_compiled = str(stmt.compile(db.ENGINE)) + logger.debug( + "[InitRec -- backend] Data to insert:\nStatement: %s\n%s", + stmt_compiled, + pformat(dump_data), + ) + + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excactly one returned row" + data_from_db = from_db[0] + + data.ids.pers_id = data_from_db["pers_id"] + data.geloescht = data_from_db["geloescht"] + data.form_data.person["Metadaten_aktualisierung"] = data_from_db["aktualisiert"] + + logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") + + else: + logger.debug("[AutoForm -- backend] Update...") + stmt = ( + db.t_grunderfassung_personen.update() + .where(db.t_grunderfassung_personen.c.pers_id == data.ids.pers_id) + .returning( + db.t_grunderfassung_personen.c.Metadaten_aktualisierung, + db.t_grunderfassung_personen.c.geloescht, + ) + ) + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = from_db[0] + + data.geloescht = data_from_db["geloescht"] + data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] + + logger.debug( + "[InitRec -- backend] Updated InitRec Person with ID %d successfully", + data.ids.pers_id, + ) + + # company + if data.recording_type is RecordingType.WITH_COMPANY: + # insert into company table and add additional information + assert data.ids.un_id, "company ID not set" + assert data.ids.an_id, "contact person (an) ID not set" + assert data.ids.pers_id, "person ID not set" + + stmt = ( + sqlite_insert(db.t_unternehmen) + .values(un_id=data.ids.un_id, geloescht=None) + .on_conflict_do_nothing(index_elements=["un_id"]) + ) + conn.execute(stmt) + + # additional information + dump_data = _rename_db_data( + data.form_data.company, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) + dump_data["geloescht"] = data.geloescht + stmt = sqlite_insert(db.t_zuordnung_personen_unternehmen).values( + **dump_data, pers_id=data.ids.pers_id + ) + stmt = stmt.on_conflict_do_update( + index_elements=["un_id", "pers_id"], + index_where=sa.text("geloescht IS NULL"), + set_={ + "an_id": stmt.excluded.an_id, + "Partnersuche__kanal_aufmerksamkeit": stmt.excluded.Partnersuche__kanal_aufmerksamkeit, + "aktualisiert": datetime.datetime.now(datetime.UTC), + }, + ).returning(db.t_zuordnung_personen_unternehmen.c.id) + ret = conn.execute(stmt) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = from_db[0] + data.ids.link_id = data_from_db["id"] + + return data def page_initrec_from_db( - ids: NewEntityIds, -) -> InitRec: ... + ids: EntityIds, +) -> InitRec: + recording_type = RecordingType.WITHOUT_COMPANY + if ids.valid(EntityType.COMPANY): + recording_type = RecordingType.WITH_COMPANY + + # person + assert ids.pers_id, "person ID not set" + logger.debug("[AutoForm -- backend] Call database loading routine...") + stmt = db.t_grunderfassung_personen.select().where( + db.t_grunderfassung_personen.c.pers_id == ids.pers_id + ) + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_person_table = dict(from_db[0]) + + data_person_table = _rename_db_data( + data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + geloescht = data_person_table["geloescht"] + del data_person_table["geloescht"] + + data_company_table: dict[str, Any] = {} + if recording_type is RecordingType.WITH_COMPANY: + assert ids.un_id, "company ID not set" + + if ids.link_id: + stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, + ).where( + db.t_zuordnung_personen_unternehmen.c.id == ids.link_id, + ) + else: + stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, + ).where( + db.t_zuordnung_personen_unternehmen.c.pers_id == ids.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id == ids.un_id, + db.t_zuordnung_personen_unternehmen.c.geloescht.is_(None), + ) + + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_company_table = dict(from_db[0]) + + data_company_table = _rename_db_data( + data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) + + form_data = Page_InitRec_Form_Data( + metadata={}, + person=data_person_table, + company=data_company_table, + ) + + return InitRec( + ids=ids, + recording_type=recording_type, + geloescht=geloescht, + form_data=form_data, + ) # def initrec_company_delete_initial_recording( @@ -857,7 +1050,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=RecordingType.COMPANY, + type=RecordingType.WITH_COMPANY, ) ) @@ -894,7 +1087,7 @@ def _main_page_get_person_list() -> list[MainPageEntry]: rec_id=rec_id, display_name=display_name, Metadaten_aktualisierung=datetime_akt, - type=RecordingType.PERSON, + type=RecordingType.WITHOUT_COMPANY, ) ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 83c83bb..e52daeb 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -7,6 +7,7 @@ import json from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar +from dopt_basics.datastructures import DualDict from pydantic import ( AwareDatetime, BaseModel, @@ -18,12 +19,12 @@ from pydantic import ( ) from pydantic_core import ErrorDetails -from wce_crm.form_defs import FormField -from wce_crm.types import NewEntityIds +from wce_crm.form_defs import INITREC_COMP, INITREC_PERSON, FormField +from wce_crm.types import EntityIds, EntityType, RecordingType if TYPE_CHECKING: from wce_crm.gui import InitRecForm, Page_Consulting_ConsultingSession # noqa: F401 - from wce_crm.types import ConsId, ConsultingType, RecId, RecordingType, UserId + from wce_crm.types import ConsId, ConsultingType, EntityType, RecId, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] COLUMN_SEP: Final[str] = "__" @@ -163,7 +164,7 @@ class InitRecFormToDb(Protocol): class InitRecFormFromDb(Protocol): def __call__( self, - ids: NewEntityIds, + ids: EntityIds, ) -> InitRec: ... @@ -171,6 +172,7 @@ class InitRecFormFromDb(Protocol): def set_page_state( target: S, source: S, + ignore_attr: set[str] | None = None, deep: bool = False, ) -> None: """ @@ -182,8 +184,12 @@ def set_page_state( if type(target) is not type(source): raise TypeError("Both source and target must have the same type") + if ignore_attr is None: + ignore_attr = set() + ignore_attr.add("child_modules") + for field in dc.fields(source): - if field.name == "child_modules": + if field.name in ignore_attr: continue new_value = getattr(source, field.name) @@ -216,35 +222,63 @@ class Page_NewEntry_State(PageState[Module]): session: Session +INITREC_REC_TO_ENTITIES: Final[dict[RecordingType, frozenset[EntityType]]] = { + RecordingType.WITH_COMPANY: frozenset((EntityType.COMPANY, EntityType.PERSON)), + RecordingType.WITHOUT_COMPANY: frozenset((EntityType.PERSON,)), +} + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_Form_Data: + metadata: dict[str, Any] + person: dict[str, Any] + company: dict[str, Any] + + # !! there must be exactly one InitRecForm for each recording type @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_State(PageState["InitRecForm"]): session: Session - ids: NewEntityIds = dc.field(default_factory=NewEntityIds) - record_type: RecordingType + ids: EntityIds = dc.field(default_factory=EntityIds) + recording_type: RecordingType # un_id: RecId | None = None locked: bool geloescht: bool = False - form_data: dict[RecordingType, dict[str, Any]] = dc.field(default_factory=dict) - pydantic_models: dict[RecordingType, type[FlatBaseModel]] = dc.field(default_factory=dict) + form_data: Page_InitRec_Form_Data + initrec_form_states: dict[EntityType, Page_InitRec_Form_State] = dc.field( + init=False, default_factory=dict + ) + # pydantic_models: dict[RecordingType, type[FlatBaseModel]] = dc.field(default_factory=dict) + + def __post_init__(self) -> None: + relevant_entities = INITREC_REC_TO_ENTITIES[self.recording_type] + for ent_type in relevant_entities: + initrec_form_state = Page_InitRec_Form_State( + session=self.session, + cfg=INITREC_CONFIGS[ent_type], + ids=self.ids, + ent_type=ent_type, + locked=self.locked, + geloescht=self.geloescht, + ) + self.initrec_form_states[ent_type] = initrec_form_state @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_Form_Config: pydantic_model: type[FlatBaseModel] - # to_db: InitRecFormToDb # TODO remove - # from_db: InitRecFormFromDb # TODO remove form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = False + id_mapping: dict[str, str] @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_Form_State(PageState[Module]): session: Session cfg: Page_InitRec_Form_Config - ids: NewEntityIds - recording_type: RecordingType + ids: EntityIds + ent_type: EntityType # rec_id: RecId | None = None form_data: dict[str, Any] = dc.field(default_factory=dict) locked: bool @@ -269,7 +303,6 @@ class Page_InitRec_Form_State(PageState[Module]): # locked: bool -# TODO change to entity IDs @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -285,7 +318,6 @@ class Page_Consulting_State(PageState[Module]): locked: bool -# TODO change to entity IDs @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Linking_State(PageState[Module]): session: Session @@ -322,7 +354,7 @@ class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"] @dc.dataclass(slots=True, kw_only=True) class Page_CompanyProfile_State(PageState[Module]): session: Session - ids: NewEntityIds + ids: EntityIds # rec_id: RecId | None = None @@ -441,10 +473,21 @@ class FlatBaseModel(BaseModel): # ** InitRec class InitRec(BaseModel): - ids: NewEntityIds - # rec_id: RecId | None + ids: EntityIds + recording_type: RecordingType geloescht: AwareDatetime | None = None - db_data: dict[RecordingType, dict[str, Any]] + form_data: Page_InitRec_Form_Data + + +# mapping between initrec form field keys and database properties +FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN: Final[DualDict[str, str]] = DualDict( + Metadaten_erstellung="erstellt", + Metadaten_aktualisierung="aktualisiert", +) +FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN: Final[DualDict[str, str]] = DualDict( + Partnersuche__un_suche="un_id", + Partnersuche__person_suche="an_id", +) class Grunderfassung_Unternehmen(FlatBaseModel): @@ -649,3 +692,34 @@ class Beratungsgespraech_Einzelgespraech(BaseModel): erstellt: AwareDatetime | None = Field(default=None, exclude=True) aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above geloescht: AwareDatetime | None = None + + +INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { + EntityType.COMPANY: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Unternehmen, + # to_db=backend.initrec_company_to_db, # TODO remove + # from_db=backend.initrec_company_from_db, # TODO remove + ignored_keys=( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + ), + form_fields=INITREC_COMP, + add_buttons=False, + id_mapping={ + "Partnersuche__un_suche": "un_id", + "Partnersuche__person_suche": "an_id", + }, + ), + EntityType.PERSON: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Personen, + # to_db=backend.initrec_person_to_db, # TODO remove + # from_db=backend.initrec_person_from_db, # TODO remove + ignored_keys=( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + ), + form_fields=INITREC_PERSON, + add_buttons=False, + id_mapping={}, + ), +} diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 1c6b883..d5450b3 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -490,6 +490,7 @@ t_zuordnung_personen_unternehmen: Table = Table( UTCDateTime, nullable=True, ), + Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True), # Column( # "gueltig_ab", # UTCDateTime, @@ -520,8 +521,8 @@ t_beratung_vorgang: Table = Table( primary_key=True, autoincrement=True, ), - Column("un_id", sa.ForeignKey("grunderfassung_unternehmen.un_id"), nullable=True), - Column("pers_id", sa.ForeignKey("grunderfassung_personen.pers_id"), nullable=True), + Column("un_id", sa.ForeignKey("Unternehmen.un_id"), nullable=True), + Column("pers_id", sa.ForeignKey("Grunderfassung_Personen.pers_id"), nullable=True), Column("titel", sa.Text, nullable=False), Column("beratungs_typ", sa.String(20), nullable=False), Column( @@ -554,7 +555,7 @@ t_beratung_einzelberatung: Table = Table( primary_key=True, autoincrement=True, ), - Column("vorgang_id", sa.ForeignKey("beratung_vorgang.vorgang_id"), nullable=False), + Column("vorgang_id", sa.ForeignKey("Beratung_Vorgang.vorgang_id"), nullable=False), Column("nutzer_id", sa.Integer, nullable=False), Column("nutzer_name", sa.String(20), nullable=False), Column( diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 765f6e4..9650dc5 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -889,42 +889,42 @@ INITREC_LANGUAGES = [ INITREC_COMP = [ - FormField( - "Ersteintrag Datum", - FormFieldType.TEXT_DATETIME, - required=False, - key="Metadaten_erstellung", - readonly=True, - ignore_get_data=True, - ), - FormField( - "Aktualisierung Datum", - FormFieldType.TEXT_DATETIME, - required=False, - key="Metadaten_aktualisierung", - readonly=True, - ignore_get_data=True, - ), - FormField( - "Aktualisierung Nutzer", - FormFieldType.TEXT, - required=False, - key="Metadaten_nutzer", - readonly=True, - ignore_get_data=True, - ), - FormField( - "Fallnummer", - FormFieldType.TEXT, - required=True, - key="Grunderfassung_fallnummer", - ), - FormField( - "Notizen", - FormFieldType.LONGTEXT, - required=False, - key="Grunderfassung_notiz", - ), + # FormField( + # "Ersteintrag Datum", + # FormFieldType.TEXT_DATETIME, + # required=False, + # key="Metadaten_erstellung", + # readonly=True, + # ignore_get_data=True, + # ), + # FormField( + # "Aktualisierung Datum", + # FormFieldType.TEXT_DATETIME, + # required=False, + # key="Metadaten_aktualisierung", + # readonly=True, + # ignore_get_data=True, + # ), + # FormField( + # "Aktualisierung Nutzer", + # FormFieldType.TEXT, + # required=False, + # key="Metadaten_nutzer", + # readonly=True, + # ignore_get_data=True, + # ), + # FormField( + # "Fallnummer", + # FormFieldType.TEXT, + # required=True, + # key="Grunderfassung_fallnummer", + # ), + # FormField( + # "Notizen", + # FormFieldType.LONGTEXT, + # required=False, + # key="Grunderfassung_notiz", + # ), FormField( "Suche", FormFieldType.CUSTOM, diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 9c82681..b9ef80f 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -85,17 +85,16 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, + INITREC_CONFIGS, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, - Grunderfassung_Personen, - Grunderfassung_Unternehmen, InitRec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, Page_Consulting_State, Page_Consulting_Table_State, - Page_InitRec_Form_Config, + Page_InitRec_Form_Data, Page_InitRec_Form_State, Page_InitRec_State, Page_MainPage_State, @@ -105,8 +104,6 @@ from wce_crm.data_models import ( translate_pydantic_errors, ) from wce_crm.form_defs import ( - INITREC_COMP, - INITREC_PERSON, FormField, FormFieldType, ) @@ -121,7 +118,8 @@ from wce_crm.logging import ( from wce_crm.types import ( CompanyProfileConsultationEntry, ConsultingType, - NewEntityIds, + EntityIds, + EntityType, RecId, RecordingType, ) @@ -1547,7 +1545,7 @@ class InitRecForm(QWidget): # self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) def relevant_ids_set(self) -> bool: - return self.STATE.ids.valid(self.STATE.recording_type) + return self.STATE.ids.valid(self.STATE.ent_type) # def _activate_delete(self) -> None: # if self.STATE.ids.valid(): @@ -1613,12 +1611,23 @@ class InitRecForm(QWidget): # pformat(self.STATE.form_data), # ) + def _load_state_form_data(self) -> None: + logger_initrecform.info("[InitRecForm] Load data method...") + logger_initrecform.debug( + "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", + pformat(self.STATE.form_data), + ) + # TODO error catching? + model = self.cfg.pydantic_model(**self.STATE.form_data) + logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") + logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") + form_data_gui = model.to_gui() + # TODO include set data in this method? + self._set_data(form_data_gui) + def reset_form(self) -> None: reset_form(self.widget_registry) - # self.STATE.ids.set_id(None, self.STATE.ent_type) - self.STATE.ids.update(NewEntityIds()) - # TODO remove - # self.STATE.rec_id = None + self.STATE.ids.update(EntityIds()) self.STATE.form_data.clear() self.STATE.geloescht = False @@ -1658,7 +1667,7 @@ class InitRecForm(QWidget): # !! loading from IDs is only performed on top-level modules aka pages if section is None or section == "form": if not self.STATE.geloescht: - self.load_from_ids(self.STATE.ids) + self._load_state_form_data() else: self.reset_form() @@ -1678,6 +1687,10 @@ class InitRecForm(QWidget): # TODO change usage of user ID (needs changes in model and backend) if self.SESSION_USER_KEY in form_data: form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name + + for form_key, id_key in self.STATE.cfg.id_mapping.items(): + self.STATE.ids.set_by_name(id_key, form_data[form_key]) + self.STATE.form_data = form_data def validate(self) -> list[str]: @@ -1695,18 +1708,18 @@ class InitRecForm(QWidget): return self.STATE - def _load_from_pydantic( - self, - data: InitRec, - ) -> None: - # ent_type = self.STATE.recording_type - # self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) - self.STATE.ids.update(data.ids) - # self.STATE.rec_id = data.rec_id - self.STATE.form_data.clear() - self.STATE.geloescht = True if data.geloescht else False + # def _load_from_pydantic( + # self, + # data: InitRec, + # ) -> None: + # # ent_type = self.STATE.recording_type + # # self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) + # self.STATE.ids.update(data.ids) + # # self.STATE.rec_id = data.rec_id + # self.STATE.form_data.clear() + # self.STATE.geloescht = True if data.geloescht else False - self._sync_state_to_GUI(None) # TODO change to correct loading + # self._sync_state_to_GUI(None) # TODO change to correct loading # !! must be part of the managing page def save_data(self) -> None: @@ -2979,7 +2992,8 @@ class Page_NewEntry(QWidget): def _request_new_initrec(self) -> None: req_state = Page_InitRec_State( session=self.STATE.session, - record_type=RecordingType.COMPANY, + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) logger_gui.debug("[Page -- NewInitRec] State to call: %s", req_state) @@ -3009,29 +3023,29 @@ class Page_NewEntry(QWidget): pass -CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( - pydantic_model=Grunderfassung_Unternehmen, - # to_db=backend.initrec_company_to_db, # TODO remove - # from_db=backend.initrec_company_from_db, # TODO remove - ignored_keys=( - "Metadaten_erstellung", - "Metadaten_aktualisierung", - ), - form_fields=INITREC_COMP, - add_buttons=False, -) +# CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( +# pydantic_model=Grunderfassung_Unternehmen, +# # to_db=backend.initrec_company_to_db, # TODO remove +# # from_db=backend.initrec_company_from_db, # TODO remove +# ignored_keys=( +# "Metadaten_erstellung", +# "Metadaten_aktualisierung", +# ), +# form_fields=INITREC_COMP, +# add_buttons=False, +# ) -CONFIG_GRUNDERFASSUNG_PERSONEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( - pydantic_model=Grunderfassung_Personen, - # to_db=backend.initrec_person_to_db, # TODO remove - # from_db=backend.initrec_person_from_db, # TODO remove - ignored_keys=( - "Metadaten_erstellung", - "Metadaten_aktualisierung", - ), - form_fields=INITREC_PERSON, - add_buttons=False, -) +# CONFIG_GRUNDERFASSUNG_PERSONEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( +# pydantic_model=Grunderfassung_Personen, +# # to_db=backend.initrec_person_to_db, # TODO remove +# # from_db=backend.initrec_person_from_db, # TODO remove +# ignored_keys=( +# "Metadaten_erstellung", +# "Metadaten_aktualisierung", +# ), +# form_fields=INITREC_PERSON, +# add_buttons=False, +# ) CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { "grunderfassung_unternehmen_suche": Grunderfassung_SuchWidget, @@ -3128,12 +3142,12 @@ class Page_InitRec(QWidget): back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) - if self.STATE.record_type is RecordingType.COMPANY: + if self.STATE.recording_type is RecordingType.WITH_COMPANY: title = QLabel("Grunderfassung Unternehmen") - elif self.STATE.record_type is RecordingType.PERSON: + elif self.STATE.recording_type is RecordingType.WITHOUT_COMPANY: title = QLabel("Grunderfassung Individualperson") else: - raise RuntimeError(f"Unspecified recording type {self.STATE.record_type}") + raise RuntimeError(f"Unspecified recording type {self.STATE.recording_type}") title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) @@ -3142,6 +3156,64 @@ class Page_InitRec(QWidget): header_layout.addWidget(title) vert_layout.addWidget(header_container) + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + header_layout.addWidget(separator1) + + btn_gui2state = QPushButton("Sync GUI to state") + btn_gui2state.clicked.connect(self._sync_GUI_to_state) + btn_gui2state.setFixedHeight(35) + btn_gui2state.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_gui2state) + + btn_state2gui = QPushButton("Sync state to GUI") + btn_state2gui.clicked.connect(self._sync_state_to_GUI) + btn_state2gui.setFixedHeight(35) + btn_state2gui.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_state2gui) + + # self.test_button = QPushButton("Initialisiere Laden") + # self.test_button.clicked.connect(self._load_from_id) + # self.test_button.setFixedHeight(35) + # self.test_button.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # header_layout.addWidget(self.test_button) + + # button_get = QPushButton("GET DATA") + # button_get.setFixedHeight(35) + # button_get.clicked.connect(self._get_form_data) + # header_layout.addWidget(button_get) + # button_set = QPushButton("SET DATA") + # button_set.setFixedHeight(35) + # button_set.clicked.connect(self._set_form_data) + # header_layout.addWidget(button_set) + + # id_field_layout = QHBoxLayout() + # id_field_layout.setContentsMargins(0, 0, 0, 0) + # id_field_layout.setSpacing(5) + # id_field_label = QLabel("ID Datenbank:") + # self.id_field_input = QLineEdit() + # id_field_layout.addWidget(id_field_label) + # id_field_layout.addWidget(self.id_field_input) + # header_layout.addLayout(id_field_layout) + # button_db_index = QPushButton("Setze DB Index") + # button_db_index.setFixedHeight(35) + # button_db_index.clicked.connect(self._set_db_index) + # header_layout.addWidget(button_db_index) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + header_layout.addWidget(separator2) + header_layout.addSpacing(5) + # --- BANNER --- self.info_banners_container = QWidget() self.info_banners: list[InfoBanner] = [] @@ -3183,35 +3255,29 @@ class Page_InitRec(QWidget): vert_layout.addWidget(scroll_area) - container_layout = QVBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) + self.container_layout = QVBoxLayout(container) + self.container_layout.setContentsMargins(0, 0, 0, 0) - # --- AUTO FORM LAYOUT 1 --- + # --- AUTO FORM LAYOUT --- # now we try to render the layout within the collapsable box - container_layout.addSpacing(20) + self.container_layout.addSpacing(20) - self.initrec_form_company: InitRecForm | None = None - if self.STATE.record_type is RecordingType.COMPANY: - # add company - pass + # ** person + self.initrec_form_person_box = CollapsibleBox("Angaben zur Person") + ent_type = EntityType.PERSON + self.initrec_form_person = InitRecForm(self.STATE.initrec_form_states[ent_type]) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_person.STATE - box1 = CollapsibleBox("Angaben zur Person") - initrec_form_state_person = Page_InitRec_Form_State( - session=self.STATE.session, - cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, - ids=self.STATE.ids, - recording_type=RecordingType.PERSON, - locked=False, - ) - self.initrec_form_person = InitRecForm(initrec_form_state_person) self.STATE.child_modules.append(self.initrec_form_person) - box1.setContentWidget(self.initrec_form_person) - container_layout.addWidget(box1) + self.initrec_form_person_box.setContentWidget(self.initrec_form_person) + self.container_layout.addWidget(self.initrec_form_person_box) # container_layout.addSpacing(15) - self.initrec_form_person.update_triggered.connect( - lambda: self.update_triggered.emit() - ) - self.initrec_form_person.update_triggered.connect(self._auto_form_updated) + # TODO trigger needed? + # self.initrec_form_person.update_triggered.connect( + # lambda: self.update_triggered.emit() + # ) + # self.initrec_form_person.update_triggered.connect(self._auto_form_updated) # --- CUSTOM LOGIC --- # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' @@ -3233,7 +3299,68 @@ class Page_InitRec(QWidget): self.selection_county.setProperty("styleClass", "stempel") self.selection_county.setEnabled(False) - container_layout.addStretch() + # ** company + ent_type = EntityType.COMPANY + self.initrec_form_company_box = CollapsibleBox("Angaben zum Unternehmen") + self.initrec_form_company = InitRecForm(self.STATE.initrec_form_states[ent_type]) + self.STATE.child_modules.append(self.initrec_form_company) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_company.STATE + + target_idx = self.container_layout.indexOf(self.initrec_form_person_box) - 1 + self.initrec_form_company_box.setContentWidget(self.initrec_form_company) + self.container_layout.insertWidget(target_idx, self.initrec_form_company_box) + + # container_layout.addSpacing(15) + # TODO trigger needed? + # self.initrec_form_person.update_triggered.connect( + # lambda: self.update_triggered.emit() + # ) + # self.initrec_form_person.update_triggered.connect(self._auto_form_updated) + + # --- CUSTOM LOGIC --- + # ** fill 'Kontaktperson -> Namen Unternehmen' + search_res = search_widgets_by_key( + self.initrec_form_company.widget_registry, "Partnersuche" + ) + assert len(search_res) == 1 + search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) + self.search_widget_trigger = cast( + QLineEdit, search_widget.company_widgets["ma_unternehmensname"] + ) + + self.container_layout.addStretch() + self._update_layout() + + def _update_layout(self) -> None: + if self.STATE.recording_type is RecordingType.WITH_COMPANY: + # add company + self.initrec_form_company_box.setVisible(True) + else: + self.initrec_form_company_box.setVisible(False) + + # elif self.initrec_form_company_box is not None: + # self.container_layout.removeWidget(self.initrec_form_company_box) + # # ** state + # assert self.initrec_form_company + # self.STATE.child_modules.remove(self.initrec_form_company) + # del self.STATE.initrec_form_states[EntityType.COMPANY] + + # self.initrec_form_company_box = None + # self.initrec_form_company = None + + # def _set_new_initrec_state( + # self, + # ent_type: EntityType, + # ) -> None: + # initrec_form_state = Page_InitRec_Form_State( + # session=self.STATE.session, + # cfg=INITREC_CONFIGS[ent_type], + # ids=self.STATE.ids, + # ent_type=ent_type, + # locked=False, + # ) + # self.STATE.initrec_form_states[ent_type] = initrec_form_state # ** custom logic def _custom_county_selection(self, idx: int) -> None: @@ -3328,18 +3455,28 @@ class Page_InitRec(QWidget): # TODO staes for form in self.STATE.child_modules: form.reset_form() - self.STATE.ids = NewEntityIds() + self.STATE.ids = EntityIds() self.STATE.geloescht = False def _load_database( self, - ids: NewEntityIds, + ids: EntityIds, ) -> None: logger_initrecform.info("[Page -- InitRec] Load data method...") logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) self._reset() - # TODO add loading logic - initrec: InitRec = ... + # TODO add loading logic with backend call + # !! just a dummy + initrec: InitRec = InitRec( + ids=ids, + recording_type=RecordingType.WITHOUT_COMPANY, + geloescht=None, + form_data=Page_InitRec_Form_Data( + metadata={}, + person={}, + company={}, + ), + ) self._load_from_pydantic(initrec, sync_to_GUI=False) @@ -3349,7 +3486,23 @@ class Page_InitRec(QWidget): update_only: bool = False, sync_to_GUI: bool = True, ) -> None: + logger_initrecform.info("[Page -- InitRec] Load from Pydantic model...") # TODO add logic to update internal state + self.STATE.recording_type = data.recording_type + self.STATE.form_data = data.form_data + self.STATE.geloescht = True if data.geloescht else False + + # set form data to correct initrec form states + for ent_type, state in self.STATE.initrec_form_states.items(): + match ent_type: + case EntityType.COMPANY: + state.form_data = self.STATE.form_data.company + # self.initrec_form_company.load_state(state) + case EntityType.PERSON: + state.form_data = self.STATE.form_data.person + # self.initrec_form_person.load_state(state) + case _: + raise RuntimeError(f"Unspecified entity type {ent_type}") if update_only: ... @@ -3368,19 +3521,20 @@ class Page_InitRec(QWidget): ) if section is None or section == "content": + self._update_layout() if not self.STATE.geloescht: self._load_database(self.STATE.ids) else: self._reset() - initrec_form_state_person = Page_InitRec_Form_State( - session=self.STATE.session, - cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, - ids=self.STATE.ids, - recording_type=RecordingType.PERSON, - locked=False, - ) - self.initrec_form_person.load_state(initrec_form_state_person) + # initrec_form_state_person = Page_InitRec_Form_State( + # session=self.STATE.session, + # cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, + # ids=self.STATE.ids, + # ent_type=RecordingType.WITHOUT_COMPANY, + # locked=False, + # ) + # self.initrec_form_person.load_state(initrec_form_state_person) if section is None or section == "banners": self._clear_info_banners() @@ -3401,22 +3555,65 @@ class Page_InitRec(QWidget): form.unlock() def _sync_GUI_to_state(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Syncing GUI to state...") + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Child modules...\n%s", + pformat(self.STATE.child_modules), + ) + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Existing form states keys...\n%s", + pformat(self.STATE.initrec_form_states.keys()), + ) + for form in self.STATE.child_modules: # ?? is this the same reference to use a similar pattern as # ?? the consulting page (initialise once with the same state # ?? as reference which are then automatically updated) + # !! check if this is working as expected + # new entries' states are linked to this instance by the page state form_state = form.get_state() - self.STATE.ids.update(form_state.ids, ignore_none=True) - self.STATE.form_data[form_state.recording_type] = form_state.form_data - self.STATE.pydantic_models[form_state.recording_type] = ( - form_state.cfg.pydantic_model + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Form state ent_type %s", + form_state.ent_type, ) + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: identity of form states: %s", + (self.STATE.initrec_form_states[form_state.ent_type] is form_state), + ) + assert self.STATE.initrec_form_states[form_state.ent_type] is form_state, ( + "passing by reference not working" + ) + self.STATE.ids.update(form_state.ids, ignore_none=True) + # self.STATE.form_data[form_state.ent_type] = form_state.form_data + match form_state.ent_type: + case EntityType.COMPANY: + self.STATE.form_data.company = form_state.form_data + case EntityType.PERSON: + self.STATE.form_data.person = form_state.form_data + case _: + raise RuntimeError(f"Unspecified entity type {form_state.ent_type}") + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: IDs after...\n%s", + pformat(self.STATE.ids), + ) + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Form data after...\n%s", + pformat(self.STATE.form_data), + ) + def load_state( self, new_state: Page_InitRec_State, ) -> None: - set_page_state(self.STATE, new_state) + set_page_state( + self.STATE, + new_state, + ignore_attr={"form_data", "initrec_form_states"}, + ) self._sync_state_to_GUI(None) def get_state(self) -> Page_InitRec_State: @@ -3444,6 +3641,8 @@ class Page_InitRec(QWidget): def save_data(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Save button clicked...") + return + if self.STATE.ids.empty() and self.STATE.geloescht: logger_page_initrec.debug( "[Page -- InitRec] This was never saved in the database " @@ -3460,7 +3659,7 @@ class Page_InitRec(QWidget): to_db = InitRec( ids=self.STATE.ids, geloescht=deleted_datetime, - db_data={}, + form_data={}, ) returned = self.cfg.to_db(to_db) self._load_from_pydantic(returned) @@ -3520,7 +3719,7 @@ class Page_InitRec(QWidget): ids=self.STATE.ids, # rec_id=self.STATE.rec_id, geloescht=deleted_datetime, - db_data=db_data, + form_data=db_data, ) returned = self.cfg.to_db(to_db) @@ -5939,8 +6138,9 @@ class MainWindow(QMainWindow): # SITE: 'Grunderfassung' newinitrec_state = Page_InitRec_State( session=session, - ids=NewEntityIds(), - record_type=RecordingType.COMPANY, + ids=EntityIds(), + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) self.newinitrec = Page_InitRec(newinitrec_state) @@ -5973,7 +6173,7 @@ class MainWindow(QMainWindow): # self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' comp_profile_state = Page_CompanyProfile_State( - session=self.STATE.session, ids=NewEntityIds() + session=self.STATE.session, ids=EntityIds() ) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index c53072f..c24c900 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -5,7 +5,7 @@ import dataclasses as dc import datetime import enum from collections.abc import Generator -from typing import TYPE_CHECKING, TypeAlias, TypedDict +from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict if TYPE_CHECKING: import polars as pl @@ -24,6 +24,12 @@ PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] # TODO rename: these are not entity types or entities but different forms of # initial recordings class RecordingType(enum.IntEnum): + WITH_COMPANY = enum.auto() + WITHOUT_COMPANY = enum.auto() + + +class EntityType(enum.IntEnum): + METADATA = enum.auto() COMPANY = enum.auto() PERSON = enum.auto() @@ -34,19 +40,36 @@ class ConsultingType(enum.StrEnum): @dc.dataclass(slots=True, kw_only=True) -class NewEntityIds: +class EntityIds: un_id: ExtMaId | None = None an_id: ExtAnId | None = None pers_id: RecId | None = None link_id: IntLinkId | None = None + _field_names: frozenset[str] = dc.field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._field_names = frozenset(f.name for f in dc.fields(EntityIds)) + + def set_by_name( + self, + attr_name: str, + value: int | None, + ) -> None: + if attr_name not in self._field_names: + raise KeyError( + f"Attribute {attr_name} not member of this class. Valid " + f"options: {self._field_names}" + ) + + setattr(self, attr_name, value) def update( self, - ids: NewEntityIds, + ids: EntityIds, ignore_none: bool = False, deep: bool = False, ) -> None: - if not isinstance(ids, NewEntityIds): + if not isinstance(ids, EntityIds): raise TypeError("New IDs must be have the same type as this object") for field in dc.fields(ids): @@ -62,18 +85,18 @@ class NewEntityIds: def _get_relevant_fields( self, - recording_type: RecordingType | None = None, + ent_type: EntityType | None = None, ) -> Generator[int | None, None, None]: - if recording_type is None: + if ent_type is None: relevant_fields = (getattr(self, field.name) for field in dc.fields(self)) - elif recording_type is RecordingType.COMPANY: + elif ent_type is EntityType.COMPANY: RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"} relevant_fields = ( getattr(self, field.name) for field in dc.fields(self) if field.name in RELEVANT_ATTRIBUTES ) - elif recording_type is RecordingType.PERSON: + elif ent_type is EntityType.PERSON: RELEVANT_ATTRIBUTES = {"pers_id"} relevant_fields = ( getattr(self, field.name) @@ -81,23 +104,23 @@ class NewEntityIds: if field.name in RELEVANT_ATTRIBUTES ) else: - raise RuntimeError(f"Unspecified recording type {recording_type}") + raise RuntimeError(f"Unspecified entity type {ent_type}") return relevant_fields def empty( self, - recording_type: RecordingType | None = None, + ent_type: EntityType | None = None, ) -> bool: - relevant_fields = self._get_relevant_fields(recording_type=recording_type) + relevant_fields = self._get_relevant_fields(ent_type=ent_type) return not any(relevant_fields) def valid( self, - recording_type: RecordingType | None = None, + ent_type: EntityType | None = None, ) -> bool: - relevant_fields = self._get_relevant_fields(recording_type=recording_type) + relevant_fields = self._get_relevant_fields(ent_type=ent_type) return all(relevant_fields) -- 2.34.1 From dc9374f5b7288affcf31b2861f783f2b07030f75 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 27 Jul 2026 11:41:02 +0200 Subject: [PATCH 56/74] migration script to latest data model --- ...18132d_update_outstanding_table_changes.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 alembic/versions/e290fe18132d_update_outstanding_table_changes.py diff --git a/alembic/versions/e290fe18132d_update_outstanding_table_changes.py b/alembic/versions/e290fe18132d_update_outstanding_table_changes.py new file mode 100644 index 0000000..ab4f5ea --- /dev/null +++ b/alembic/versions/e290fe18132d_update_outstanding_table_changes.py @@ -0,0 +1,38 @@ +"""update outstanding table changes + +Revision ID: e290fe18132d +Revises: 8000d15faf03 +Create Date: 2026-07-27 09:05:06.301043 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e290fe18132d" +down_revision: Union[str, Sequence[str], None] = "8000d15faf03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.Text(), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.drop_column("Partnersuche__kanal_aufmerksamkeit") + + # ### end Alembic commands ### -- 2.34.1 From 1ff55038fa9efded3a346999977da703a7c26608 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 27 Jul 2026 11:41:24 +0200 Subject: [PATCH 57/74] basic functionality of loading/saving for initial recording with new data model --- src/wce_crm/backend/backend.py | 99 +++++++++++++------ src/wce_crm/data_models.py | 14 +-- src/wce_crm/form_defs.py | 2 +- src/wce_crm/gui.py | 173 +++++++++++++++++++++++---------- src/wce_crm/types.py | 21 ++-- 5 files changed, 207 insertions(+), 102 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index f21406c..3d9ff09 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -402,11 +402,16 @@ def page_initrec_to_db( from_db = ret.mappings().fetchall() assert len(from_db) == 1, "expected excactly one returned row" - data_from_db = from_db[0] + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) data.ids.pers_id = data_from_db["pers_id"] data.geloescht = data_from_db["geloescht"] - data.form_data.person["Metadaten_aktualisierung"] = data_from_db["aktualisiert"] + data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ + "Metadaten_aktualisierung" + ] logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") @@ -416,7 +421,7 @@ def page_initrec_to_db( db.t_grunderfassung_personen.update() .where(db.t_grunderfassung_personen.c.pers_id == data.ids.pers_id) .returning( - db.t_grunderfassung_personen.c.Metadaten_aktualisierung, + db.t_grunderfassung_personen.c.aktualisiert, db.t_grunderfassung_personen.c.geloescht, ) ) @@ -424,7 +429,10 @@ def page_initrec_to_db( from_db = ret.mappings().fetchall() assert len(from_db) == 1, "expected excatly one returned row" - data_from_db = from_db[0] + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) data.geloescht = data_from_db["geloescht"] data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ @@ -450,14 +458,28 @@ def page_initrec_to_db( ) conn.execute(stmt) - # additional information + # additional information --> linking table dump_data = _rename_db_data( data.form_data.company, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN ) - dump_data["geloescht"] = data.geloescht + logger.debug( + "[InitRec -- backend] Company - additional info: Dump Data = \n%s", + pformat(dump_data), + ) + if "un_id" not in dump_data: + # only case when updated + dump_data["un_id"] = data.ids.un_id + dump_data["an_id"] = data.ids.an_id + stmt = sqlite_insert(db.t_zuordnung_personen_unternehmen).values( **dump_data, pers_id=data.ids.pers_id ) + # dump_data["geloescht"] = data.geloescht + # logger.debug( + # "[InitRec -- backend] Company - additional info: Dump Data = \n%s", + # pformat(dump_data), + # ) + stmt = stmt.on_conflict_do_update( index_elements=["un_id", "pers_id"], index_where=sa.text("geloescht IS NULL"), @@ -465,13 +487,17 @@ def page_initrec_to_db( "an_id": stmt.excluded.an_id, "Partnersuche__kanal_aufmerksamkeit": stmt.excluded.Partnersuche__kanal_aufmerksamkeit, "aktualisiert": datetime.datetime.now(datetime.UTC), + "geloescht": data.geloescht, }, ).returning(db.t_zuordnung_personen_unternehmen.c.id) ret = conn.execute(stmt) from_db = ret.mappings().fetchall() assert len(from_db) == 1, "expected excatly one returned row" - data_from_db = from_db[0] + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) data.ids.link_id = data_from_db["id"] return data @@ -480,35 +506,18 @@ def page_initrec_to_db( def page_initrec_from_db( ids: EntityIds, ) -> InitRec: + logger.debug("[InitRec -- backend] Call database loading routine...") + recording_type = RecordingType.WITHOUT_COMPANY - if ids.valid(EntityType.COMPANY): + if ids.link_id is not None or ids.valid(EntityType.COMPANY): recording_type = RecordingType.WITH_COMPANY - # person - assert ids.pers_id, "person ID not set" - logger.debug("[AutoForm -- backend] Call database loading routine...") - stmt = db.t_grunderfassung_personen.select().where( - db.t_grunderfassung_personen.c.pers_id == ids.pers_id - ) - with db.ENGINE.connect() as conn: - ret = conn.execute(stmt) - - from_db = ret.mappings().all() - assert len(from_db) == 1, "not excactly one company initial recording obtained" - data_person_table = dict(from_db[0]) - - data_person_table = _rename_db_data( - data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN - ) - geloescht = data_person_table["geloescht"] - del data_person_table["geloescht"] - data_company_table: dict[str, Any] = {} if recording_type is RecordingType.WITH_COMPANY: - assert ids.un_id, "company ID not set" - + logger.debug("[InitRec -- backend] With company. Link ID: %s", ids.link_id) if ids.link_id: stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.pers_id, db.t_zuordnung_personen_unternehmen.c.un_id, db.t_zuordnung_personen_unternehmen.c.an_id, db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, @@ -516,7 +525,10 @@ def page_initrec_from_db( db.t_zuordnung_personen_unternehmen.c.id == ids.link_id, ) else: + assert ids.un_id, "company ID not set" + assert ids.pers_id, "person ID not set" stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.pers_id, db.t_zuordnung_personen_unternehmen.c.un_id, db.t_zuordnung_personen_unternehmen.c.an_id, db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, @@ -533,16 +545,43 @@ def page_initrec_from_db( assert len(from_db) == 1, "not excactly one company initial recording obtained" data_company_table = dict(from_db[0]) + ids.pers_id = data_company_table.pop("pers_id") + ids.un_id = data_company_table["un_id"] + ids.an_id = data_company_table["an_id"] data_company_table = _rename_db_data( - data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + data_company_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN ) + # person + assert ids.pers_id, "person ID not set" + logger.debug("[InitRec -- backend] Person... Person ID: %s", ids.pers_id) + stmt = db.t_grunderfassung_personen.select().where( + db.t_grunderfassung_personen.c.pers_id == ids.pers_id + ) + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_person_table = dict(from_db[0]) + + data_person_table = _rename_db_data( + data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + geloescht = data_person_table["geloescht"] + del data_person_table["geloescht"] + form_data = Page_InitRec_Form_Data( metadata={}, person=data_person_table, company=data_company_table, ) + if geloescht is not None: + logger.warning( + "[InitRec -- backend] Loaded entries which seem to be deleted - IDs: %s", ids + ) + return InitRec( ids=ids, recording_type=recording_type, diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index e52daeb..eb3b028 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -492,11 +492,11 @@ FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN: Final[DualDict[str, str]] = DualD class Grunderfassung_Unternehmen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime - Metadaten_erstellung: AwareDatetime | None = None - Metadaten_aktualisierung: AwareDatetime | None = None # see above - Metadaten_nutzer: str | None - Grunderfassung_fallnummer: str - Grunderfassung_notiz: str | None + # Metadaten_erstellung: AwareDatetime | None = None + # Metadaten_aktualisierung: AwareDatetime | None = None # see above + # Metadaten_nutzer: str | None + # Grunderfassung_fallnummer: str + # Grunderfassung_notiz: str | None Partnersuche: Grunderfassung_PartnerSuche @@ -697,8 +697,6 @@ class Beratungsgespraech_Einzelgespraech(BaseModel): INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { EntityType.COMPANY: Page_InitRec_Form_Config( pydantic_model=Grunderfassung_Unternehmen, - # to_db=backend.initrec_company_to_db, # TODO remove - # from_db=backend.initrec_company_from_db, # TODO remove ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", @@ -712,8 +710,6 @@ INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { ), EntityType.PERSON: Page_InitRec_Form_Config( pydantic_model=Grunderfassung_Personen, - # to_db=backend.initrec_person_to_db, # TODO remove - # from_db=backend.initrec_person_from_db, # TODO remove ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 9650dc5..49afab2 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -957,7 +957,7 @@ INITREC_PERSON = [ required=False, key="Metadaten_nutzer", readonly=True, - ignore_get_data=True, + ignore_get_data=False, ), FormField( "Wiedereintrittsdatum", diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index b9ef80f..ddeae88 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1622,7 +1622,6 @@ class InitRecForm(QWidget): logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") form_data_gui = model.to_gui() - # TODO include set data in this method? self._set_data(form_data_gui) def reset_form(self) -> None: @@ -2996,7 +2995,7 @@ class Page_NewEntry(QWidget): form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) - logger_gui.debug("[Page -- NewInitRec] State to call: %s", req_state) + logger_gui.debug("[Page -- NewInitRec] State to call (IDs): %s", req_state.ids) self.new_initrec_requested.emit(req_state) def _sync_state_to_GUI(self) -> None: @@ -3178,6 +3177,14 @@ class Page_InitRec(QWidget): ) header_layout.addWidget(btn_state2gui) + btn_print_state = QPushButton("Print State") + btn_print_state.clicked.connect(self._debug_print_state) + btn_print_state.setFixedHeight(35) + btn_print_state.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_print_state) + # self.test_button = QPushButton("Initialisiere Laden") # self.test_button.clicked.connect(self._load_from_id) # self.test_button.setFixedHeight(35) @@ -3332,6 +3339,11 @@ class Page_InitRec(QWidget): self.container_layout.addStretch() self._update_layout() + def _debug_print_state(self) -> None: + logger_initrecform.info( + "[Page -- InitRec] Printing state IDs:\n%s", pformat(self.STATE.ids) + ) + def _update_layout(self) -> None: if self.STATE.recording_type is RecordingType.WITH_COMPANY: # add company @@ -3428,7 +3440,16 @@ class Page_InitRec(QWidget): self.delete_btn.setEnabled(False) def _deletion_valid(self) -> bool: - valid_id_states = (form.relevant_ids_set() for form in self.STATE.child_modules) + # valid_id_states: list[bool] = [] + relevant_ent_types: set[EntityType] = {EntityType.METADATA, EntityType.PERSON} + if self.STATE.recording_type is RecordingType.WITH_COMPANY: + relevant_ent_types.add(EntityType.COMPANY) + + valid_id_states = ( + form.relevant_ids_set() + for form in self.STATE.child_modules + if form.STATE.ent_type in relevant_ent_types + ) return all(valid_id_states) def _delete_data(self) -> None: @@ -3465,18 +3486,50 @@ class Page_InitRec(QWidget): logger_initrecform.info("[Page -- InitRec] Load data method...") logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) self._reset() + + ids = EntityIds( + # un_id=3, + # an_id=7, + pers_id=5, + ) + + ids = EntityIds( + link_id=7, + ) + self.STATE.ids.update(ids, ignore_none=False) + + if ids.empty(): + logger_initrecform.debug("[Page -- InitRec] Loading: No ID set. Do nothing!") + return + # TODO add loading logic with backend call # !! just a dummy - initrec: InitRec = InitRec( - ids=ids, - recording_type=RecordingType.WITHOUT_COMPANY, - geloescht=None, - form_data=Page_InitRec_Form_Data( - metadata={}, - person={}, - company={}, - ), - ) + initrec = backend.page_initrec_from_db(ids) + + # TODO re-enable later + # if res.status != STATUS_HANDLER.SUCCESS: + # logger_page_consulting.error( + # ( + # "[Consulting Page] There were errors during the database " + # "loading procedure." + # ), + # stack_info=True, + # ) + + # exc_formatted = ( + # f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + # ) + # msg_box = get_message_box( + # QMessageBox.Icon.Warning, + # "Laden fehlgeschlagen", + # ( + # "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind " + # "unten angefügt." + # ), + # detailed_text=exc_formatted, + # ) + # msg_box.exec() + # return self._load_from_pydantic(initrec, sync_to_GUI=False) @@ -3487,28 +3540,31 @@ class Page_InitRec(QWidget): sync_to_GUI: bool = True, ) -> None: logger_initrecform.info("[Page -- InitRec] Load from Pydantic model...") - # TODO add logic to update internal state + self.STATE.ids.update(data.ids) self.STATE.recording_type = data.recording_type self.STATE.form_data = data.form_data self.STATE.geloescht = True if data.geloescht else False # set form data to correct initrec form states for ent_type, state in self.STATE.initrec_form_states.items(): + state.ids.update(self.STATE.ids) + state.geloescht = self.STATE.geloescht + match ent_type: case EntityType.COMPANY: state.form_data = self.STATE.form_data.company - # self.initrec_form_company.load_state(state) + if ( + not update_only + and self.STATE.recording_type is RecordingType.WITH_COMPANY + ): + self.initrec_form_company.load_state(state) case EntityType.PERSON: state.form_data = self.STATE.form_data.person - # self.initrec_form_person.load_state(state) + if not update_only: + self.initrec_form_person.load_state(state) case _: raise RuntimeError(f"Unspecified entity type {ent_type}") - if update_only: - ... - else: - ... - if sync_to_GUI: self._sync_state_to_GUI(None) @@ -3517,24 +3573,15 @@ class Page_InitRec(QWidget): section: Literal["content", "banners", "locking"] | None, ) -> None: logger_page_initrec.debug( - "[Page -- InitRec] Syncing state to GUI: State...\n%s", pformat(self.STATE) + "[Page -- InitRec] Syncing state to GUI: State IDs: %s", self.STATE.ids ) if section is None or section == "content": - self._update_layout() if not self.STATE.geloescht: self._load_database(self.STATE.ids) else: self._reset() - - # initrec_form_state_person = Page_InitRec_Form_State( - # session=self.STATE.session, - # cfg=CONFIG_GRUNDERFASSUNG_PERSONEN, - # ids=self.STATE.ids, - # ent_type=RecordingType.WITHOUT_COMPANY, - # locked=False, - # ) - # self.initrec_form_person.load_state(initrec_form_state_person) + self._update_layout() if section is None or section == "banners": self._clear_info_banners() @@ -3546,13 +3593,14 @@ class Page_InitRec(QWidget): if section is None or section == "locking": if self.STATE.locked: - self.edit_buttons.set_modify_state(False, set_delete_btn=True) for form in self.STATE.child_modules: form.lock() + self.edit_buttons.set_modify_state(False, set_delete_btn=True) else: - self.edit_buttons.set_modify_state(True, set_delete_btn=False) for form in self.STATE.child_modules: form.unlock() + self.edit_buttons.set_modify_state(True, set_delete_btn=False) + self._activate_delete() def _sync_GUI_to_state(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Syncing GUI to state...") @@ -3641,7 +3689,13 @@ class Page_InitRec(QWidget): def save_data(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Save button clicked...") - return + logger_page_initrec.info("[Page -- InitRec] Syncing current state...") + # state = self.get_state() + self._sync_GUI_to_state() + form_data = self.STATE.form_data + assert form_data, "form data not set" + logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data)) + db_data = Page_InitRec_Form_Data(metadata={}, person={}, company={}) if self.STATE.ids.empty() and self.STATE.geloescht: logger_page_initrec.debug( @@ -3656,12 +3710,13 @@ class Page_InitRec(QWidget): ) deleted_datetime = datetime.datetime.now(datetime.UTC) assert self.STATE.form_data, "cannot delete entry with uninitialised form data" - to_db = InitRec( + data_to_db = InitRec( ids=self.STATE.ids, + recording_type=self.STATE.recording_type, geloescht=deleted_datetime, - form_data={}, + form_data=db_data, ) - returned = self.cfg.to_db(to_db) + returned = backend.page_initrec_to_db(data_to_db) self._load_from_pydantic(returned) msg_box = get_message_box( QMessageBox.Icon.Information, @@ -3679,33 +3734,45 @@ class Page_InitRec(QWidget): logger_page_initrec.error( "[Page -- InitRec] Error during GUI validation phase:\n%s", pformat(errors) ) - GUI_pydantic_validation_error_handling(errors) + GUI_validation_error_handling(errors) self._enable_save() return logger_page_initrec.info("[Page -- InitRec] Success! All data is valid") - logger_page_initrec.info("[Page -- InitRec] Get form data call...") - state = self.get_state() - form_data = state.form_data - assert form_data, "form data not set" - - logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data)) try: logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...") - validated_data = self.cfg.pydantic_model(**form_data) + # validated_data = self.cfg.pydantic_model(**form_data) + + for ent_type, state in self.STATE.initrec_form_states.items(): + logger_page_initrec.debug( + "[Page -- InitRec] Ent type: %s, Current state CFG model: %s", + ent_type, + state.cfg.pydantic_model, + ) + match ent_type: + case EntityType.COMPANY: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.company) + db_data.company = val_data.to_db(exclude=state.cfg.ignored_keys) + case EntityType.PERSON: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.person) + db_data.person = val_data.to_db(exclude=state.cfg.ignored_keys) + case _: + raise RuntimeError(f"Unspecified entity type {ent_type}") + except ValidationError as e: logger_page_initrec.error( "[Page -- InitRec] Error during Pydantic validation phase:\n%s", pformat(e.errors()), ) pydantic_validation_error_handling(e) + else: if DEBUG_NO_DATABASE: return # !! this code is only called if the 'try' block was successful # save data to database - db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) logger_page_initrec.debug( "[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), @@ -3715,15 +3782,15 @@ class Page_InitRec(QWidget): deleted_datetime = datetime.datetime.now(datetime.UTC) try: # TODO change to result pattern - to_db = InitRec( + data_to_db = InitRec( ids=self.STATE.ids, - # rec_id=self.STATE.rec_id, + recording_type=self.STATE.recording_type, geloescht=deleted_datetime, form_data=db_data, ) - returned = self.cfg.to_db(to_db) + returned = backend.page_initrec_to_db(data_to_db) - self._load_from_pydantic(returned) + self._load_from_pydantic(returned, update_only=True, sync_to_GUI=False) self.update_triggered.emit() except Exception as err: msg_box = get_message_box( @@ -4958,7 +5025,7 @@ class Page_Consulting(QWidget): logger_page_consulting.error( "[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors) ) - GUI_pydantic_validation_error_handling(errors) + GUI_validation_error_handling(errors) self._enable_save() return @@ -5964,7 +6031,7 @@ def pydantic_validation_error_handling( msg_box.exec() -def GUI_pydantic_validation_error_handling( +def GUI_validation_error_handling( errors: list[str], ) -> None: error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join( diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index c24c900..1bb14aa 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -48,7 +48,9 @@ class EntityIds: _field_names: frozenset[str] = dc.field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - self._field_names = frozenset(f.name for f in dc.fields(EntityIds)) + field_names = set(f.name for f in dc.fields(EntityIds)) + field_names.remove("_field_names") + self._field_names = frozenset(field_names) def set_by_name( self, @@ -88,20 +90,21 @@ class EntityIds: ent_type: EntityType | None = None, ) -> Generator[int | None, None, None]: if ent_type is None: - relevant_fields = (getattr(self, field.name) for field in dc.fields(self)) + relevant_fields = (getattr(self, field_name) for field_name in self._field_names) elif ent_type is EntityType.COMPANY: - RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"} + # RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"} + RELEVANT_ATTRIBUTES = {"un_id", "an_id"} relevant_fields = ( - getattr(self, field.name) - for field in dc.fields(self) - if field.name in RELEVANT_ATTRIBUTES + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES ) elif ent_type is EntityType.PERSON: RELEVANT_ATTRIBUTES = {"pers_id"} relevant_fields = ( - getattr(self, field.name) - for field in dc.fields(self) - if field.name in RELEVANT_ATTRIBUTES + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES ) else: raise RuntimeError(f"Unspecified entity type {ent_type}") -- 2.34.1 From ba26baa2254c5d0503fbb45eb7794aa52e8b4777 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 27 Jul 2026 12:38:00 +0200 Subject: [PATCH 58/74] re-enable company profile --- src/wce_crm/backend/backend.py | 24 ++-- src/wce_crm/data_models.py | 7 +- src/wce_crm/gui.py | 240 ++++++++++++++------------------- 3 files changed, 114 insertions(+), 157 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 3d9ff09..ea5da04 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -75,7 +75,8 @@ def initrec_comp_search_choices() -> tuple[tuple[str, int], ...]: return tuple(zip(df["dedupl"], df["ma_id"])) -def initrec_comp_search_get_info( +# TODO change to direct SQL interaction +def external_company_get_info( ma_id: ExtMaId, ) -> CompanyInfo: logger.debug("[Call backend] comp_search_get_info") @@ -858,11 +859,11 @@ def page_consulting_linking_companies( with db.ENGINE.connect() as conn: q_relevant_companies = sa.select( - db.grunderfassung_unternehmen.c.un_id, - db.grunderfassung_unternehmen.c.Partnersuche__un_suche, + db.t_unternehmen.c.un_id, + db.t_unternehmen.c.Partnersuche__un_suche, ).where( - db.grunderfassung_unternehmen.c.geloescht.is_(None), - db.grunderfassung_unternehmen.c.Partnersuche__un_suche.is_not(None), + db.t_unternehmen.c.geloescht.is_(None), + db.t_unternehmen.c.Partnersuche__un_suche.is_not(None), ) if un_id is not None: q_relevant_companies = q_relevant_companies.where( @@ -904,6 +905,8 @@ def page_consulting_linking_companies( def page_consulting_linking_persons( un_id: RecId | None, ) -> list[LinkingConsultationsEntryPerson]: + raise RuntimeError("TODOs to implement") + with db.ENGINE.connect() as conn: # if un_id None: list all persons # else: list just the persons associated with the company @@ -958,6 +961,7 @@ def page_consulting_eastablish_link( un_id: RecId | None, pers_id: RecId | None, ) -> None: + raise RuntimeError("TODOs to implement") stmt = ( sa.update(db.t_beratung_vorgang) @@ -969,6 +973,7 @@ def page_consulting_eastablish_link( conn.execute(stmt) +# // company profile interaction def companyprofile_page_get_consultations( un_id: RecId, ) -> CompanyProfileConsultations: @@ -994,11 +999,8 @@ def companyprofile_page_get_consultations( datetime_updated = cast(datetime.datetime, entry["aktualisiert"]) datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST) - base_title = entry["titel"] - - _cons_type = entry["beratungs_typ"] - cons_type = ConsultingType(_cons_type) + cons_type = ConsultingType(entry["beratungs_typ"]) con_entry = CompanyProfileConsultationEntry( cons_id=cons_id, @@ -1012,7 +1014,7 @@ def companyprofile_page_get_consultations( elif cons_type is ConsultingType.INDIVIDUAL: cons_entries_individual.append(con_entry) else: - raise TypeError(f"Unknown consulting type: {_cons_type}") + raise TypeError(f"Unknown consulting type: {cons_type}") cons_entries_pauschal.sort(key=lambda x: x.date_updated, reverse=True) cons_entries_individual.sort(key=lambda x: x.date_updated, reverse=True) @@ -1081,7 +1083,7 @@ def _main_page_get_company_list() -> list[MainPageEntry]: datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) datetime_akt = datetime_akt.astimezone(TIMEZONE_CEST) - comp_info = initrec_comp_search_get_info(ma_id_external) + comp_info = external_company_get_info(ma_id_external) display_name = comp_info["ma_unternehmensname"] main_page_companies.append( diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index eb3b028..a0fcad7 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -20,11 +20,11 @@ from pydantic import ( from pydantic_core import ErrorDetails from wce_crm.form_defs import INITREC_COMP, INITREC_PERSON, FormField -from wce_crm.types import EntityIds, EntityType, RecordingType +from wce_crm.types import ConsultingType, EntityIds, EntityType, RecordingType if TYPE_CHECKING: from wce_crm.gui import InitRecForm, Page_Consulting_ConsultingSession # noqa: F401 - from wce_crm.types import ConsId, ConsultingType, EntityType, RecId, UserId + from wce_crm.types import ConsId, EntityType, RecId, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] COLUMN_SEP: Final[str] = "__" @@ -354,8 +354,7 @@ class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"] @dc.dataclass(slots=True, kw_only=True) class Page_CompanyProfile_State(PageState[Module]): session: Session - ids: EntityIds - # rec_id: RecId | None = None + un_id: RecId | None = None # // Pydantic models diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index ddeae88..ea8a5e5 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -85,7 +85,6 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, - INITREC_CONFIGS, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, InitRec, @@ -128,7 +127,6 @@ if TYPE_CHECKING: from wce_crm.data_models import ( Module, Page_InitRec_Form_State, - WrapperModule, ) from wce_crm.types import ( ConsId, @@ -1273,7 +1271,7 @@ class Grunderfassung_SuchWidget(CustomFormWidget): if ma_id is None or index == (-1): self._clear_company_fields() return - data = backend.initrec_comp_search_get_info( + data = backend.external_company_get_info( ma_id=ma_id, ) self.fill_out_company(data) @@ -1378,7 +1376,7 @@ def search_widgets_by_key( class InitRecForm(QWidget): """a widget, which is managed by a code-defined field definition collection""" - update_triggered = Signal() # formular saved (data changed for front page) + # update_triggered = Signal() # formular saved (data changed for front page) def __init__( self, @@ -1425,54 +1423,54 @@ class InitRecForm(QWidget): self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) - if DEBUG: - separator1 = QFrame() - separator1.setFrameShape(QFrame.Shape.HLine) - separator1.setFrameShadow(QFrame.Shadow.Sunken) - self.main_layout.addWidget(separator1) + # if DEBUG: + # separator1 = QFrame() + # separator1.setFrameShape(QFrame.Shape.HLine) + # separator1.setFrameShadow(QFrame.Shadow.Sunken) + # self.main_layout.addWidget(separator1) - self.registry_button = QPushButton("Ausgabe Widget Registry") - self.registry_button.clicked.connect(self._print_registry) - self.registry_button.setFixedHeight(35) - self.main_layout.addWidget(self.registry_button) + # self.registry_button = QPushButton("Ausgabe Widget Registry") + # self.registry_button.clicked.connect(self._print_registry) + # self.registry_button.setFixedHeight(35) + # self.main_layout.addWidget(self.registry_button) - # self.test_button = QPushButton("Initialisiere Laden") - # self.test_button.clicked.connect(self.load_from_ids) - # self.test_button.setFixedHeight(35) - # self.test_button.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # self.main_layout.addWidget(self.test_button) + # # self.test_button = QPushButton("Initialisiere Laden") + # # self.test_button.clicked.connect(self.load_from_ids) + # # self.test_button.setFixedHeight(35) + # # self.test_button.setSizePolicy( + # # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # # ) + # # self.main_layout.addWidget(self.test_button) - button_get = QPushButton("GET DATA") - button_get.setFixedHeight(35) - button_get.clicked.connect(self._get_data) - self.main_layout.addWidget(button_get) - button_set = QPushButton("SET DATA") - button_set.setFixedHeight(35) - button_set.clicked.connect(self._set_data) - self.main_layout.addWidget(button_set) + # button_get = QPushButton("GET DATA") + # button_get.setFixedHeight(35) + # button_get.clicked.connect(self._get_data) + # self.main_layout.addWidget(button_get) + # button_set = QPushButton("SET DATA") + # button_set.setFixedHeight(35) + # button_set.clicked.connect(self._set_data) + # self.main_layout.addWidget(button_set) - id_field_layout = QHBoxLayout() - id_field_layout.setContentsMargins(0, 0, 0, 0) - id_field_layout.setSpacing(5) - id_field_label = QLabel("ID Datenbank:") - self.id_field_input = QLineEdit() - id_field_layout.addWidget(id_field_label) - id_field_layout.addWidget(self.id_field_input) - self.main_layout.addLayout(id_field_layout) - button_db_index = QPushButton("Setze DB Index") - button_db_index.setFixedHeight(35) - button_db_index.clicked.connect(self._set_db_index) - self.main_layout.addWidget(button_db_index) + # id_field_layout = QHBoxLayout() + # id_field_layout.setContentsMargins(0, 0, 0, 0) + # id_field_layout.setSpacing(5) + # id_field_label = QLabel("ID Datenbank:") + # self.id_field_input = QLineEdit() + # id_field_layout.addWidget(id_field_label) + # id_field_layout.addWidget(self.id_field_input) + # self.main_layout.addLayout(id_field_layout) + # button_db_index = QPushButton("Setze DB Index") + # button_db_index.setFixedHeight(35) + # button_db_index.clicked.connect(self._set_db_index) + # self.main_layout.addWidget(button_db_index) - separator2 = QFrame() - separator2.setFrameShape(QFrame.Shape.HLine) - separator2.setFrameShadow(QFrame.Shadow.Sunken) - self.main_layout.addWidget(separator2) - self.main_layout.addSpacing(5) + # separator2 = QFrame() + # separator2.setFrameShape(QFrame.Shape.HLine) + # separator2.setFrameShadow(QFrame.Shadow.Sunken) + # self.main_layout.addWidget(separator2) + # self.main_layout.addSpacing(5) - self.main_layout.addSpacing(10) + # self.main_layout.addSpacing(10) # buttons (save and reset) self.add_buttons = self.cfg.add_buttons @@ -1519,14 +1517,14 @@ class InitRecForm(QWidget): def _print_registry(self) -> None: pprint_registry(self.widget_registry) - def _set_db_index(self) -> None: - try: - index = int(self.id_field_input.text()) - except ValueError: - index = None - self.STATE.ids.un_id = index - self.STATE.ids.pers_id = index - logger_gui.debug("[InitRecForm] Set index to %s, new state: %s", index, self.STATE) + # def _set_db_index(self) -> None: + # try: + # index = int(self.id_field_input.text()) + # except ValueError: + # index = None + # self.STATE.ids.un_id = index + # self.STATE.ids.pers_id = index + # logger_gui.debug("[InitRecForm] Set index to %s, new state: %s", index, self.STATE) # def _disable_save(self) -> None: # assert self.save_btn is not None @@ -3104,6 +3102,7 @@ class Page_InitRec(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) + company_profile_requested = Signal(Page_CompanyProfile_State) def __init__( self, @@ -3153,6 +3152,15 @@ class Page_InitRec(QWidget): header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) + + self.comp_profile_btn = QPushButton("Unternehmensprofil") + self.comp_profile_btn.clicked.connect(self._request_company_profile) + self.comp_profile_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.comp_profile_btn.setFixedHeight(50) + header_layout.addWidget(self.comp_profile_btn) + vert_layout.addWidget(header_container) if DEBUG: @@ -3185,36 +3193,6 @@ class Page_InitRec(QWidget): ) header_layout.addWidget(btn_print_state) - # self.test_button = QPushButton("Initialisiere Laden") - # self.test_button.clicked.connect(self._load_from_id) - # self.test_button.setFixedHeight(35) - # self.test_button.setSizePolicy( - # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - # ) - # header_layout.addWidget(self.test_button) - - # button_get = QPushButton("GET DATA") - # button_get.setFixedHeight(35) - # button_get.clicked.connect(self._get_form_data) - # header_layout.addWidget(button_get) - # button_set = QPushButton("SET DATA") - # button_set.setFixedHeight(35) - # button_set.clicked.connect(self._set_form_data) - # header_layout.addWidget(button_set) - - # id_field_layout = QHBoxLayout() - # id_field_layout.setContentsMargins(0, 0, 0, 0) - # id_field_layout.setSpacing(5) - # id_field_label = QLabel("ID Datenbank:") - # self.id_field_input = QLineEdit() - # id_field_layout.addWidget(id_field_label) - # id_field_layout.addWidget(self.id_field_input) - # header_layout.addLayout(id_field_layout) - # button_db_index = QPushButton("Setze DB Index") - # button_db_index.setFixedHeight(35) - # button_db_index.clicked.connect(self._set_db_index) - # header_layout.addWidget(button_db_index) - separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -3279,12 +3257,6 @@ class Page_InitRec(QWidget): self.STATE.child_modules.append(self.initrec_form_person) self.initrec_form_person_box.setContentWidget(self.initrec_form_person) self.container_layout.addWidget(self.initrec_form_person_box) - # container_layout.addSpacing(15) - # TODO trigger needed? - # self.initrec_form_person.update_triggered.connect( - # lambda: self.update_triggered.emit() - # ) - # self.initrec_form_person.update_triggered.connect(self._auto_form_updated) # --- CUSTOM LOGIC --- # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' @@ -3318,13 +3290,6 @@ class Page_InitRec(QWidget): self.initrec_form_company_box.setContentWidget(self.initrec_form_company) self.container_layout.insertWidget(target_idx, self.initrec_form_company_box) - # container_layout.addSpacing(15) - # TODO trigger needed? - # self.initrec_form_person.update_triggered.connect( - # lambda: self.update_triggered.emit() - # ) - # self.initrec_form_person.update_triggered.connect(self._auto_form_updated) - # --- CUSTOM LOGIC --- # ** fill 'Kontaktperson -> Namen Unternehmen' search_res = search_widgets_by_key( @@ -3344,35 +3309,31 @@ class Page_InitRec(QWidget): "[Page -- InitRec] Printing state IDs:\n%s", pformat(self.STATE.ids) ) + def _request_company_profile(self) -> None: + assert self.STATE.ids.un_id is not None, ( + "requested company profile for a company which is not known (no ID)" + ) + req_state = Page_CompanyProfile_State( + session=self.STATE.session, + un_id=self.STATE.ids.un_id, + ) + logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + self.company_profile_requested.emit(req_state) + def _update_layout(self) -> None: if self.STATE.recording_type is RecordingType.WITH_COMPANY: # add company self.initrec_form_company_box.setVisible(True) + self.comp_profile_btn.setVisible(True) + if self.STATE.ids.valid(EntityType.COMPANY): + self.comp_profile_btn.setEnabled(True) + else: + self.comp_profile_btn.setEnabled(False) + else: self.initrec_form_company_box.setVisible(False) - - # elif self.initrec_form_company_box is not None: - # self.container_layout.removeWidget(self.initrec_form_company_box) - # # ** state - # assert self.initrec_form_company - # self.STATE.child_modules.remove(self.initrec_form_company) - # del self.STATE.initrec_form_states[EntityType.COMPANY] - - # self.initrec_form_company_box = None - # self.initrec_form_company = None - - # def _set_new_initrec_state( - # self, - # ent_type: EntityType, - # ) -> None: - # initrec_form_state = Page_InitRec_Form_State( - # session=self.STATE.session, - # cfg=INITREC_CONFIGS[ent_type], - # ids=self.STATE.ids, - # ent_type=ent_type, - # locked=False, - # ) - # self.STATE.initrec_form_states[ent_type] = initrec_form_state + self.comp_profile_btn.setVisible(False) + self.comp_profile_btn.setEnabled(False) # ** custom logic def _custom_county_selection(self, idx: int) -> None: @@ -4099,13 +4060,13 @@ class Page_CompanyProfile(QWidget): self, cons_type: ConsultingType, ) -> None: - assert self.STATE.ids.un_id is not None, ( + assert self.STATE.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=None, - un_id=self.STATE.ids.un_id, + un_id=self.STATE.un_id, beratungs_typ=cons_type, locked=False, ) @@ -4115,13 +4076,13 @@ class Page_CompanyProfile(QWidget): self, data: CompanyProfileConsultationEntry, ) -> None: - assert self.STATE.ids.un_id is not None, ( + assert self.STATE.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=data.cons_id, - un_id=self.STATE.ids.un_id, + un_id=self.STATE.un_id, beratungs_typ=data.cons_type, locked=True, ) @@ -4143,11 +4104,9 @@ class Page_CompanyProfile(QWidget): widget.deleteLater() def _update_consultations(self) -> None: - if self.STATE.ids.un_id is None: + if self.STATE.un_id is None: return - consultations = backend.companyprofile_page_get_consultations( - un_id=self.STATE.ids.un_id - ) + consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.un_id) logger_gui.debug( "[Page -- Company Profile] Consultations: %s", pformat(consultations) @@ -4185,7 +4144,7 @@ class Page_CompanyProfile(QWidget): self.add_btn_individual.setEnabled(True) def _sync_state_to_GUI(self) -> None: - if self.STATE.ids.un_id is None: + if self.STATE.un_id is None: # just clear and do nothing else logger_gui.debug("just reset, ID is None") self.table_master_data.reset() @@ -4195,11 +4154,11 @@ class Page_CompanyProfile(QWidget): self._enable_add_btns() # TODO change to correct backend function - raise RuntimeError("TODOs to implement") - init_rec = backend.initrec_company_get_initial_recording(self.STATE.ids.un_id) - ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) - assert ma_id is not None, "no ma_id obtained" - comp_info = backend.initrec_comp_search_get_info(ma_id) + # raise RuntimeError("TODOs to implement") + # init_rec = backend.initrec_company_get_initial_recording(self.STATE.un_id) + # ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) + # assert ma_id is not None, "no ma_id obtained" + comp_info = backend.external_company_get_info(self.STATE.un_id) data: dict[str, Any] = {} @@ -4221,8 +4180,8 @@ class Page_CompanyProfile(QWidget): self, new_state: Page_CompanyProfile_State, ) -> None: - logger_gui.debug("Called function to load with IDs: %s", pformat(new_state.ids)) - self.STATE.ids = new_state.ids + logger_gui.debug("Called function to load with ID: %s", pformat(new_state.un_id)) + self.STATE.un_id = new_state.un_id self._sync_state_to_GUI() @@ -6213,7 +6172,8 @@ class MainWindow(QMainWindow): self.newinitrec = Page_InitRec(newinitrec_state) self.newinitrec.back_main_requested.connect(self.show_main_page) self.newinitrec.back_requested.connect(self.simple_go_back) - # self.newinitrec.update_main_page.connect(self.update_grid) + self.newinitrec.company_profile_requested.connect(self.show_page_company_profile) + # self.newinitrec.update_triggered.connect(self.update_grid) self.stack.addWidget(self.newinitrec) # SITE: 'Grunderfassung Unternehmen' @@ -6239,9 +6199,7 @@ class MainWindow(QMainWindow): # self.initrec_person.update_triggered.connect(self.update_grid) # self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' - comp_profile_state = Page_CompanyProfile_State( - session=self.STATE.session, ids=EntityIds() - ) + comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) # TODO remove @@ -6526,8 +6484,6 @@ if TYPE_CHECKING: _m3: type[Module] = Page_Consulting_ConsultingSession _m4: type[Module] = Page_InitRec _m5: type[Module] = InitRecForm - # _w1: type[WrapperModule] = Page_InitRecCompany - # _w2: type[WrapperModule] = Page_InitRecPerson _f1: type[CustomForm] = DynamicListWidget _f2: type[CustomForm] = DynamicDropdownWidgetNumeric _f3: type[CustomForm] = DynamicDropdownWidgetOption -- 2.34.1 From 449d401c04f4671daca08af3df8989132eb8c9f5 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 27 Jul 2026 19:50:24 +0200 Subject: [PATCH 59/74] handle metadata separately in GUI, but common in database --- prototypes/tests.py | 32 +++++++++++++ src/wce_crm/backend/backend.py | 73 ++++++++++++++++++----------- src/wce_crm/data_models.py | 84 ++++++++++++++++++++++++++-------- src/wce_crm/form_defs.py | 80 +++++++++++++++++++++++++------- src/wce_crm/gui.py | 60 ++++++++++++++++++++++-- src/wce_crm/types.py | 7 +++ 6 files changed, 271 insertions(+), 65 deletions(-) diff --git a/prototypes/tests.py b/prototypes/tests.py index 3644836..b6ee6a0 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -21,6 +21,38 @@ from wce_crm import constants, db from wce_crm.backend import backend from wce_crm.types import EntityIds, RecordingType +# %% +empty = tuple() + +# not any(empty) + +all(empty) +# %% +dic = { + "t1": "test1", + "t2": "test2", + "t3": "test3", + "t4": "test4", +} +filter_keys = frozenset(("t3", "t2")) + +dic_in, dic_out = backend.split_dict(dic, filter_keys) + +print(f"{dic_in=}") +print(f"{dic_out=}") + + +# %% +dic_update = { + "t5": "new", + "t6": "new_2", +} + +dic_updated = backend.dic_deep_update(dic, dic_update) +print(f"{dic=}") +print(f"{dic_update=}") +print(f"{dic_updated=}") + # %% raw_ent_ids = EntityIds() db_ent_ids = EntityIds(un_id=3, an_id=2) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index ea5da04..6781ce7 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -2,8 +2,9 @@ from __future__ import annotations import copy import datetime +from collections.abc import Container, Mapping from pprint import pformat -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import polars as pl import sqlalchemy as sa @@ -15,6 +16,7 @@ from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN, + FIELD_DB_SPLIT_METADATEN, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, InitRec, @@ -40,6 +42,9 @@ if TYPE_CHECKING: from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId +K = TypeVar("K") +V = TypeVar("V") + def _transform_for_gui_output( data: pl.DataFrame, @@ -355,6 +360,30 @@ def initrec_comp_contact_person_search_get_info( # return dict(row) +def split_dict( + dic: dict[K, V], + split_keys: Container[K], +) -> tuple[dict[K, V], dict[K, V]]: + keys_in: dict[K, V] = {} + keys_out = copy.deepcopy(dic) + + for check_key in dic: + if check_key in split_keys: + keys_in[check_key] = keys_out.pop(check_key) + + return keys_in, keys_out + + +def dic_deep_update( + dic: dict[K, V], + data: Mapping[K, V], +) -> dict[K, V]: + dic_copy = copy.deepcopy(dic) + dic_copy.update(data) + + return dic_copy + + def _rename_db_data( db_data: dict[str, Any], mapping: DualDict, @@ -379,18 +408,15 @@ def page_initrec_to_db( with db.ENGINE.begin() as conn: # person - dump_data = _rename_db_data( - data.form_data.person, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN - ) + dump_data = dic_deep_update(data.form_data.person, data.form_data.metadata) + dump_data = _rename_db_data(dump_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) dump_data["geloescht"] = data.geloescht if data.ids.pers_id is None: logger.debug("[InitRec -- backend] Insert...") stmt = sa.insert(db.t_grunderfassung_personen).returning( - db.t_grunderfassung_personen.c.pers_id, - db.t_grunderfassung_personen.c.aktualisiert, - db.t_grunderfassung_personen.c.geloescht, + db.t_grunderfassung_personen ) stmt_compiled = str(stmt.compile(db.ENGINE)) logger.debug( @@ -409,10 +435,6 @@ def page_initrec_to_db( ) data.ids.pers_id = data_from_db["pers_id"] - data.geloescht = data_from_db["geloescht"] - data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") @@ -421,10 +443,7 @@ def page_initrec_to_db( stmt = ( db.t_grunderfassung_personen.update() .where(db.t_grunderfassung_personen.c.pers_id == data.ids.pers_id) - .returning( - db.t_grunderfassung_personen.c.aktualisiert, - db.t_grunderfassung_personen.c.geloescht, - ) + .returning(db.t_grunderfassung_personen) ) ret = conn.execute(stmt, dump_data) @@ -435,16 +454,22 @@ def page_initrec_to_db( data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN ) - data.geloescht = data_from_db["geloescht"] - data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ - "Metadaten_aktualisierung" - ] + # data.geloescht = data_from_db["geloescht"] + # data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ + # "Metadaten_aktualisierung" + # ] logger.debug( "[InitRec -- backend] Updated InitRec Person with ID %d successfully", data.ids.pers_id, ) + data.geloescht = data_from_db["geloescht"] + del data_from_db["geloescht"] + in_filter, out_filter = split_dict(data_from_db, FIELD_DB_SPLIT_METADATEN) + data.form_data.metadata = in_filter + data.form_data.person = out_filter + # company if data.recording_type is RecordingType.WITH_COMPANY: # insert into company table and add additional information @@ -475,11 +500,6 @@ def page_initrec_to_db( stmt = sqlite_insert(db.t_zuordnung_personen_unternehmen).values( **dump_data, pers_id=data.ids.pers_id ) - # dump_data["geloescht"] = data.geloescht - # logger.debug( - # "[InitRec -- backend] Company - additional info: Dump Data = \n%s", - # pformat(dump_data), - # ) stmt = stmt.on_conflict_do_update( index_elements=["un_id", "pers_id"], @@ -571,10 +591,11 @@ def page_initrec_from_db( ) geloescht = data_person_table["geloescht"] del data_person_table["geloescht"] + in_filter, out_filter = split_dict(data_person_table, FIELD_DB_SPLIT_METADATEN) form_data = Page_InitRec_Form_Data( - metadata={}, - person=data_person_table, + metadata=in_filter, + person=out_filter, company=data_company_table, ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index a0fcad7..fb6b97f 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -19,7 +19,7 @@ from pydantic import ( ) from pydantic_core import ErrorDetails -from wce_crm.form_defs import INITREC_COMP, INITREC_PERSON, FormField +from wce_crm.form_defs import INITREC_COMP, INITREC_METADATEN, INITREC_PERSON, FormField from wce_crm.types import ConsultingType, EntityIds, EntityType, RecordingType if TYPE_CHECKING: @@ -222,10 +222,22 @@ class Page_NewEntry_State(PageState[Module]): session: Session -INITREC_REC_TO_ENTITIES: Final[dict[RecordingType, frozenset[EntityType]]] = { - RecordingType.WITH_COMPANY: frozenset((EntityType.COMPANY, EntityType.PERSON)), - RecordingType.WITHOUT_COMPANY: frozenset((EntityType.PERSON,)), -} +# TODO check removal +# INITREC_REC_TO_ENTITIES: Final[dict[RecordingType, frozenset[EntityType]]] = { +# RecordingType.WITH_COMPANY: frozenset( +# ( +# EntityType.METADATA, +# EntityType.COMPANY, +# EntityType.PERSON, +# ) +# ), +# RecordingType.WITHOUT_COMPANY: frozenset( +# ( +# EntityType.METADATA, +# EntityType.PERSON, +# ) +# ), +# } @dc.dataclass(slots=True, kw_only=True) @@ -251,7 +263,16 @@ class Page_InitRec_State(PageState["InitRecForm"]): # pydantic_models: dict[RecordingType, type[FlatBaseModel]] = dc.field(default_factory=dict) def __post_init__(self) -> None: - relevant_entities = INITREC_REC_TO_ENTITIES[self.recording_type] + # TODO check removal + # relevant_entities = INITREC_REC_TO_ENTITIES[self.recording_type] + relevant_entities = frozenset( + ( + EntityType.METADATA, + EntityType.COMPANY, + EntityType.PERSON, + ) + ) + for ent_type in relevant_entities: initrec_form_state = Page_InitRec_Form_State( session=self.session, @@ -483,11 +504,33 @@ FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN: Final[DualDict[str, str]] = DualDict Metadaten_erstellung="erstellt", Metadaten_aktualisierung="aktualisiert", ) + FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN: Final[DualDict[str, str]] = DualDict( Partnersuche__un_suche="un_id", Partnersuche__person_suche="an_id", ) +FIELD_DB_SPLIT_METADATEN: Final[frozenset[str]] = frozenset( + ( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + "Metadaten_nutzer", + "Metadaten_wiedereintrittsdatum", + "Grunderfassung_fallnummer", + "Grunderfassung_notiz", + ) +) + + +class Grunderfassung_Metadaten(FlatBaseModel): + # displayed and handled separately, on database level included in 'Person' + Metadaten_erstellung: AwareDatetime | None = None + Metadaten_aktualisierung: AwareDatetime | None = None # see above + Metadaten_nutzer: str | None + Metadaten_wiedereintrittsdatum: datetime.date | None = None + Grunderfassung_fallnummer: str + Grunderfassung_notiz: str | None + class Grunderfassung_Unternehmen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime @@ -502,12 +545,9 @@ class Grunderfassung_Unternehmen(FlatBaseModel): class Grunderfassung_Personen(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime - Metadaten_erstellung: AwareDatetime | None = None - Metadaten_aktualisierung: AwareDatetime | None = None # see above - Metadaten_nutzer: str | None - Metadaten_wiedereintrittsdatum: datetime.date | None = None - Grunderfassung_fallnummer: str - Grunderfassung_notiz: str | None + # Metadaten_erstellung: AwareDatetime | None = None + # Metadaten_aktualisierung: AwareDatetime | None = None # see above + # Metadaten_nutzer: str | None Projektrelevanz: Grunderfassung_Projektrelevanz Kontaktperson: Grunderfassung_Kontaktperson @@ -694,12 +734,19 @@ class Beratungsgespraech_Einzelgespraech(BaseModel): INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { - EntityType.COMPANY: Page_InitRec_Form_Config( - pydantic_model=Grunderfassung_Unternehmen, + EntityType.METADATA: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Metadaten, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", ), + form_fields=INITREC_METADATEN, + add_buttons=False, + id_mapping={}, + ), + EntityType.COMPANY: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Unternehmen, + ignored_keys=tuple(), form_fields=INITREC_COMP, add_buttons=False, id_mapping={ @@ -709,10 +756,11 @@ INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { ), EntityType.PERSON: Page_InitRec_Form_Config( pydantic_model=Grunderfassung_Personen, - ignored_keys=( - "Metadaten_erstellung", - "Metadaten_aktualisierung", - ), + ignored_keys=tuple(), + # ignored_keys=( + # "Metadaten_erstellung", + # "Metadaten_aktualisierung", + # ), form_fields=INITREC_PERSON, add_buttons=False, id_mapping={}, diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 49afab2..59e8005 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -187,7 +187,7 @@ COUNTRY_LIST: Final[CountryList] = get_country_list_german() GERMAN_STATE_LIST: Final[CountryList] = get_list_germany_states() -INITREC_COMP_SEARCH_HEAD = [ +_INITREC_COMP_SEARCH_HEAD = [ FormField( "Suche", FormFieldType.EXTENDED_DROPDOWN, @@ -329,7 +329,7 @@ INITREC_COMP_SEARCH_HEAD = [ ), ] -INITREC_CONTACT_PERSON = [ +_INITREC_CONTACT_PERSON = [ # TODO check removal FormField( "Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)", @@ -400,7 +400,7 @@ INITREC_CONTACT_PERSON = [ ] -INITREC_MASTER_DATA = [ +_INITREC_MASTER_DATA = [ FormField( "Titel", FormFieldType.TEXT, @@ -560,7 +560,7 @@ INITREC_MASTER_DATA = [ ] -INITREC_ADDITIONAL_DATA = [ +_INITREC_ADDITIONAL_DATA = [ FormField( "Deutsch als Kommunikationssprache", FormFieldType.DROPDOWN, @@ -635,7 +635,7 @@ INITREC_ADDITIONAL_DATA = [ ] -INITREC_SCHOOL = [ +_INITREC_SCHOOL = [ FormField("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"), FormField( "Abschlussgrad laut Dokument", @@ -673,7 +673,7 @@ INITREC_SCHOOL = [ ] -INITREC_HIGHER_EDUCATION = [ +_INITREC_HIGHER_EDUCATION = [ FormField( "Anerkennung", FormFieldType.TEXT, @@ -742,7 +742,7 @@ INITREC_HIGHER_EDUCATION = [ ] -INITREC_WORK_EXPERIENCE = [ +_INITREC_WORK_EXPERIENCE = [ FormField( "Branche", FormFieldType.DROPDOWN, @@ -835,7 +835,7 @@ INITREC_WORK_EXPERIENCE = [ ] -INITREC_LANGUAGES = [ +_INITREC_LANGUAGES = [ FormField( "Sprache", FormFieldType.TEXT, @@ -930,11 +930,11 @@ INITREC_COMP = [ FormFieldType.CUSTOM, custom_widget="grunderfassung_unternehmen_suche", key="Partnersuche", - children=INITREC_COMP_SEARCH_HEAD, + children=_INITREC_COMP_SEARCH_HEAD, ), ] -INITREC_PERSON = [ +INITREC_METADATEN = [ FormField( "Ersteintrag Datum", FormFieldType.TEXT_DATETIME, @@ -978,6 +978,52 @@ INITREC_PERSON = [ required=False, key="Grunderfassung_notiz", ), +] + +INITREC_PERSON = [ + # FormField( + # "Ersteintrag Datum", + # FormFieldType.TEXT_DATETIME, + # required=False, + # key="Metadaten_erstellung", + # readonly=True, + # ignore_get_data=True, + # ), + # FormField( + # "Aktualisierung Datum", + # FormFieldType.TEXT_DATETIME, + # required=False, + # key="Metadaten_aktualisierung", + # readonly=True, + # ignore_get_data=True, + # ), + # FormField( + # "Aktualisierung Nutzer", + # FormFieldType.TEXT, + # required=False, + # key="Metadaten_nutzer", + # readonly=True, + # ignore_get_data=False, + # ), + # FormField( + # "Wiedereintrittsdatum", + # FormFieldType.DATE, + # required=False, + # key="Metadaten_wiedereintrittsdatum", + # readonly=False, + # ), + # FormField( + # "Fallnummer", + # FormFieldType.TEXT, + # required=True, + # key="Grunderfassung_fallnummer", + # ), + # FormField( + # "Notizen", + # FormFieldType.LONGTEXT, + # required=False, + # key="Grunderfassung_notiz", + # ), FormField( "Status && Projektrelevanz", FormFieldType.GROUP, @@ -1009,42 +1055,42 @@ INITREC_PERSON = [ "Daten Kontaktperson", FormFieldType.GROUP, key="Kontaktperson", - children=INITREC_CONTACT_PERSON, + children=_INITREC_CONTACT_PERSON, ), FormField( "Stammdaten", FormFieldType.GROUP, key="Stammdaten", - children=INITREC_MASTER_DATA, + children=_INITREC_MASTER_DATA, ), FormField( "Weitere Informationen", FormFieldType.GROUP, key="WeitereInfos", - children=INITREC_ADDITIONAL_DATA, + children=_INITREC_ADDITIONAL_DATA, ), FormField( "Schulbildung", FormFieldType.DYNAMIC_LIST, - children=INITREC_SCHOOL, + children=_INITREC_SCHOOL, key="Schulbildung", ), FormField( "Studium/Ausbildung", FormFieldType.DYNAMIC_LIST, - children=INITREC_HIGHER_EDUCATION, + children=_INITREC_HIGHER_EDUCATION, key="HoehereBildung", ), FormField( "Arbeitserfahrung", FormFieldType.DYNAMIC_LIST, - children=INITREC_WORK_EXPERIENCE, + children=_INITREC_WORK_EXPERIENCE, key="Arbeitserfahrung", ), FormField( "Sprachkenntnisse", FormFieldType.DYNAMIC_LIST, - children=INITREC_LANGUAGES, + children=_INITREC_LANGUAGES, key="Sprachkenntnisse", ), ] diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index ea8a5e5..e3cf0cb 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1616,6 +1616,11 @@ class InitRecForm(QWidget): pformat(self.STATE.form_data), ) # TODO error catching? + logger_initrecform.info( + "[InitRecForm] Pydantic model name: %s", + self.STATE.cfg.pydantic_model.__name__, + ) + model = self.cfg.pydantic_model(**self.STATE.form_data) logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") @@ -3097,6 +3102,12 @@ class CollapsibleBox(QWidget): layout.addWidget(widget) self.setContentLayout(layout) + def set_open( + self, + open: bool, + ) -> None: + self._on_toggle(open) + class Page_InitRec(QWidget): back_main_requested = Signal() # back to main page @@ -3247,6 +3258,28 @@ class Page_InitRec(QWidget): # now we try to render the layout within the collapsable box self.container_layout.addSpacing(20) + # ** metadata + self.initrec_form_metadata_box = CollapsibleBox("Metadaten") + ent_type = EntityType.METADATA + self.initrec_form_metadata = InitRecForm(self.STATE.initrec_form_states[ent_type]) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_metadata.STATE + + self.STATE.child_modules.append(self.initrec_form_metadata) + self.initrec_form_metadata_box.setContentWidget(self.initrec_form_metadata) + self.container_layout.addWidget(self.initrec_form_metadata_box) + + # --- CUSTOM LOGIC --- + # ** 'Wiedereintrittsdatum' -> only relevant without company + search_res = search_widgets_by_key( + self.initrec_form_metadata.widget_registry, "wiedereintrittsdatum" + ) + print(f">>>> {pformat(search_res)}") + assert len(search_res) == 1 + field_wiedereintrittsdatum = search_res[0]["widget"] + assert isinstance(field_wiedereintrittsdatum, QDateEdit) + self.field_wiedereintrittsdatum = field_wiedereintrittsdatum + # ** person self.initrec_form_person_box = CollapsibleBox("Angaben zur Person") ent_type = EntityType.PERSON @@ -3286,7 +3319,7 @@ class Page_InitRec(QWidget): assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_company.STATE - target_idx = self.container_layout.indexOf(self.initrec_form_person_box) - 1 + target_idx = self.container_layout.indexOf(self.initrec_form_person_box) self.initrec_form_company_box.setContentWidget(self.initrec_form_company) self.container_layout.insertWidget(target_idx, self.initrec_form_company_box) @@ -3321,9 +3354,13 @@ class Page_InitRec(QWidget): self.company_profile_requested.emit(req_state) def _update_layout(self) -> None: + self.initrec_form_metadata_box.set_open(True) if self.STATE.recording_type is RecordingType.WITH_COMPANY: # add company self.initrec_form_company_box.setVisible(True) + self.initrec_form_metadata.top_level_form_layout.setRowVisible( + self.field_wiedereintrittsdatum, False + ) self.comp_profile_btn.setVisible(True) if self.STATE.ids.valid(EntityType.COMPANY): self.comp_profile_btn.setEnabled(True) @@ -3332,6 +3369,9 @@ class Page_InitRec(QWidget): else: self.initrec_form_company_box.setVisible(False) + self.initrec_form_metadata.top_level_form_layout.setRowVisible( + self.field_wiedereintrittsdatum, True + ) self.comp_profile_btn.setVisible(False) self.comp_profile_btn.setEnabled(False) @@ -3454,9 +3494,9 @@ class Page_InitRec(QWidget): pers_id=5, ) - ids = EntityIds( - link_id=7, - ) + # ids = EntityIds( + # link_id=7, + # ) self.STATE.ids.update(ids, ignore_none=False) if ids.empty(): @@ -3523,6 +3563,13 @@ class Page_InitRec(QWidget): state.form_data = self.STATE.form_data.person if not update_only: self.initrec_form_person.load_state(state) + case EntityType.METADATA: + state.form_data = self.STATE.form_data.metadata + logger_initrecform.info( + "[Page -- InitRec] Load METADATA from Pydantic model...\n%s", + pformat(self.STATE.form_data.metadata), + ) + self.initrec_form_metadata.load_state(state) case _: raise RuntimeError(f"Unspecified entity type {ent_type}") @@ -3602,6 +3649,8 @@ class Page_InitRec(QWidget): self.STATE.form_data.company = form_state.form_data case EntityType.PERSON: self.STATE.form_data.person = form_state.form_data + case EntityType.METADATA: + self.STATE.form_data.metadata = form_state.form_data case _: raise RuntimeError(f"Unspecified entity type {form_state.ent_type}") @@ -3718,6 +3767,9 @@ class Page_InitRec(QWidget): case EntityType.PERSON: val_data = state.cfg.pydantic_model(**self.STATE.form_data.person) db_data.person = val_data.to_db(exclude=state.cfg.ignored_keys) + case EntityType.METADATA: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.metadata) + db_data.metadata = val_data.to_db(exclude=state.cfg.ignored_keys) case _: raise RuntimeError(f"Unspecified entity type {ent_type}") diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 1bb14aa..45d2a17 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -106,6 +106,13 @@ class EntityIds: for field_name in self._field_names if field_name in RELEVANT_ATTRIBUTES ) + elif ent_type is EntityType.METADATA: + RELEVANT_ATTRIBUTES = set() + relevant_fields = ( + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES + ) else: raise RuntimeError(f"Unspecified entity type {ent_type}") -- 2.34.1 From a515f3b820bf09601b5071e0b3a073e80f9d05eb Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 28 Jul 2026 08:27:45 +0200 Subject: [PATCH 60/74] adapt customer requirements, REQ 1006, 1007 --- ...fc1dfdf_update_schema_to_meet_customer_.py | 48 +++++ data_model/project_db_schema.aml | 3 +- src/wce_crm/backend/backend.py | 6 +- src/wce_crm/data_models.py | 3 +- src/wce_crm/db.py | 31 +--- src/wce_crm/form_defs.py | 166 ++++-------------- src/wce_crm/gui.py | 19 +- 7 files changed, 100 insertions(+), 176 deletions(-) create mode 100644 alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py diff --git a/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py b/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py new file mode 100644 index 0000000..24e62ec --- /dev/null +++ b/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py @@ -0,0 +1,48 @@ +"""update schema to meet customer requirements + +Revision ID: 5dfa1fc1dfdf +Revises: e290fe18132d +Create Date: 2026-07-28 08:39:44.168872 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5dfa1fc1dfdf" +down_revision: Union[str, Sequence[str], None] = "e290fe18132d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Grunderfassung_Personen", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Stammdaten__kanal_aufmerksamkeit", sa.Text(), nullable=True) + ) + + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column(sa.Column("Partnersuche__kontaktweg", sa.Text(), nullable=True)) + batch_op.drop_column("Partnersuche__kanal_aufmerksamkeit") + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True) + ) + batch_op.drop_column("Partnersuche__kontaktweg") + + with op.batch_alter_table("Grunderfassung_Personen", schema=None) as batch_op: + batch_op.drop_column("Stammdaten__kanal_aufmerksamkeit") + + # ### end Alembic commands ### diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 12c8bfc..7b10d53 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -15,7 +15,7 @@ Personen_Unternehmen_Zuordnung pers_id int pk -> Grunderfassung_Personen(pers_id) unique=uq_aktive_zuordnung un_id int -> Unternehmen(un_id) unique=uq_aktive_zuordnung | alias 'Partnersuche__un_suche' an_id int -> Ext_Kontaktliste.Ansprechpartner(an_id) | alias 'Partnersuche__person_suche - Partnersuche__kanal_aufmerksamkeit text nullable + Partnersuche__kontaktweg text nullable erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} geloescht "timestamp with time zone" nullable | Soft-Delete Marker (partieller Unique-Index: WHERE geloescht IS NULL) @@ -45,6 +45,7 @@ Grunderfassung_Personen Projektrelevanz__relevanz text nullable Projektrelevanz__foerderperiode text nullable Schulbildung text nullable + Stammdaten__kanal_aufmerksamkeit text nullable Stammdaten__PLZ text nullable Stammdaten__anrede_anschrift text nullable Stammdaten__anzahl_kinder__alter text nullable diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 6781ce7..228e51e 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -506,7 +506,7 @@ def page_initrec_to_db( index_where=sa.text("geloescht IS NULL"), set_={ "an_id": stmt.excluded.an_id, - "Partnersuche__kanal_aufmerksamkeit": stmt.excluded.Partnersuche__kanal_aufmerksamkeit, + "Partnersuche__kontaktweg": stmt.excluded.Partnersuche__kontaktweg, "aktualisiert": datetime.datetime.now(datetime.UTC), "geloescht": data.geloescht, }, @@ -541,7 +541,7 @@ def page_initrec_from_db( db.t_zuordnung_personen_unternehmen.c.pers_id, db.t_zuordnung_personen_unternehmen.c.un_id, db.t_zuordnung_personen_unternehmen.c.an_id, - db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg, ).where( db.t_zuordnung_personen_unternehmen.c.id == ids.link_id, ) @@ -552,7 +552,7 @@ def page_initrec_from_db( db.t_zuordnung_personen_unternehmen.c.pers_id, db.t_zuordnung_personen_unternehmen.c.un_id, db.t_zuordnung_personen_unternehmen.c.an_id, - db.t_zuordnung_personen_unternehmen.c.Partnersuche__kanal_aufmerksamkeit, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg, ).where( db.t_zuordnung_personen_unternehmen.c.pers_id == ids.pers_id, db.t_zuordnung_personen_unternehmen.c.un_id == ids.un_id, diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index fb6b97f..922c425 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -564,7 +564,7 @@ class Grunderfassung_PartnerSuche(BaseModel): un_suche: int | None person_suche: int | None - kanal_aufmerksamkeit: str | None + kontaktweg: str | None class Grunderfassung_Projektrelevanz(BaseModel): @@ -592,6 +592,7 @@ class Grunderfassung_Kontaktperson(BaseModel): class Grunderfassung_Stammdaten(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + kanal_aufmerksamkeit: str | None titel: str | None anrede_anschrift: str name: str diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index d5450b3..2b4b231 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -397,19 +397,6 @@ t_grunderfassung_personen: Table = Table( UTCDateTime, nullable=True, ), - # Column( - # "Metadaten_erstellung", - # UTCDateTime, - # nullable=True, - # default=lambda: datetime.datetime.now(datetime.UTC), - # ), - # Column( - # "Metadaten_aktualisierung", - # UTCDateTime, - # nullable=True, - # default=lambda: datetime.datetime.now(datetime.UTC), - # onupdate=lambda: datetime.datetime.now(datetime.UTC), - # ), Column("Metadaten_nutzer", sa.String(20), nullable=True), Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), Column("Arbeitserfahrung", sa.Text, nullable=True), @@ -423,15 +410,14 @@ t_grunderfassung_personen: Table = Table( Column("Kontaktperson__KP_funktion_beziehung", sa.Text, nullable=True), Column("Kontaktperson__KP_mobilfunknummer", sa.Text, nullable=True), Column("Kontaktperson__KP_name", sa.Text, nullable=True), - Column( - "Kontaktperson__KP_name_partner", sa.Text, nullable=True - ), # TODO: check if needed when set by trigger + Column("Kontaktperson__KP_name_partner", sa.Text, nullable=True), Column("Kontaktperson__KP_titel", sa.Text, nullable=True), Column("Kontaktperson__KP_vorname", sa.Text, nullable=True), Column("Projektrelevanz__relevanz", sa.Text, nullable=True), Column("Projektrelevanz__foerderperiode", sa.Text, nullable=True), Column("Schulbildung", sa.Text, nullable=True), Column("Sprachkenntnisse", sa.Text, nullable=True), + Column("Stammdaten__kanal_aufmerksamkeit", sa.Text, nullable=True), Column("Stammdaten__PLZ", sa.Text, nullable=True), Column("Stammdaten__anrede_anschrift", sa.Text, nullable=True), Column("Stammdaten__anzahl_kinder__alter", sa.Text, nullable=True), @@ -490,18 +476,7 @@ t_zuordnung_personen_unternehmen: Table = Table( UTCDateTime, nullable=True, ), - Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True), - # Column( - # "gueltig_ab", - # UTCDateTime, - # nullable=False, - # default=lambda: datetime.datetime.now(datetime.UTC), - # ), - # Column( - # "gueltig_bis", - # UTCDateTime, - # nullable=True, - # ), + Column("Partnersuche__kontaktweg", sa.Text, nullable=True), Index( "uq_aktive_zuordnung", "un_id", diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 59e8005..aa476d5 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -307,53 +307,38 @@ _INITREC_COMP_SEARCH_HEAD = [ info="an_position", ), FormField( - "Wie sind Sie auf uns aufmerksam geworden?", + "Kontaktweg", FormFieldType.DROPDOWN, required=False, - key="kanal_aufmerksamkeit", + key="kontaktweg", options=[ - ("Agentur für Arbeit", None), - ("Ausländerbehörde", None), - ("Jobcenter", None), - ("Freunde/Familie", None), - ("Anerkennungsstelle", None), - ("Beratungsstelle", None), - ("Internet", None), - ("Arbeitgeber", None), - ("Bildungsdienstleister", None), - ("Welcome-Mappe", None), - ("Newsletter WFE", None), - ("Newsletter RM", None), - ("Sonstiges", None), + ("Telefon", None), + ("Mail", None), + ("Persönliches Gespräch", None), ], ), ] _INITREC_CONTACT_PERSON = [ - # TODO check removal FormField( - "Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)", + "Name Unternehmen/Netzwerkpartner", FormFieldType.TEXT, key="KP_name_partner", required=False, - placeholder="[TODO: Logik in Verbindung mit Unternehmen unklar]", - readonly=True, + readonly=False, ), FormField( "Titel", FormFieldType.TEXT, key="KP_titel", required=False, - tooltip=( - "* nur wenn anrufende Person oder kontaktaufnehmende Person " - "nicht die zu beratende Person ist" - ), ), FormField( - "Anrede_Anschrift", - FormFieldType.TEXT, + "Anrede", + FormFieldType.DROPDOWN, key="KP_anrede_anschrift", required=True, + options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)], ), FormField( "Name", @@ -401,21 +386,39 @@ _INITREC_CONTACT_PERSON = [ _INITREC_MASTER_DATA = [ + FormField( + "Wie sind Sie auf uns aufmerksam geworden?", + FormFieldType.DROPDOWN, + required=False, + key="kanal_aufmerksamkeit", + options=[ + ("Agentur für Arbeit", None), + ("Ausländerbehörde", None), + ("Jobcenter", None), + ("Freunde/Familie", None), + ("Anerkennungsstelle", None), + ("Beratungsstelle", None), + ("Internet", None), + ("Arbeitgeber", None), + ("Bildungsdienstleister", None), + ("Welcome-Mappe", None), + ("Newsletter WFE", None), + ("Newsletter RM", None), + ("Sonstiges", None), + ], + ), FormField( "Titel", FormFieldType.TEXT, key="titel", required=False, - tooltip=( - "* nur wenn anrufende Person oder kontaktaufnehmende Person " - "nicht die zu beratende Person ist" - ), ), FormField( "Anrede", - FormFieldType.TEXT, + FormFieldType.DROPDOWN, key="anrede_anschrift", required=True, + options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)], ), FormField( "Name", @@ -427,17 +430,13 @@ _INITREC_MASTER_DATA = [ "Vorname", FormFieldType.TEXT, key="vorname", - required=False, + required=True, ), FormField( "Geburtsdatum", FormFieldType.DATE, key="geburtsdatum", required=False, - tooltip=( - "* Wichtig zu erfragen, da u.a. Mindestgehaltsschwelle davon abhängt " - "(Regelung bei Ü45 Jahre)" - ), ), FormField( "Herkunftsland", @@ -446,7 +445,6 @@ _INITREC_MASTER_DATA = [ required=True, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Staatsangehörigkeit", @@ -455,7 +453,6 @@ _INITREC_MASTER_DATA = [ required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Rückkehrer", @@ -463,7 +460,6 @@ _INITREC_MASTER_DATA = [ key="rueckkehrer", required=False, options=[("ja", None), ("nein", None)], - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Wo befindet sich die Person?", @@ -502,7 +498,6 @@ _INITREC_MASTER_DATA = [ key="bundesland", required=False, options=GERMAN_STATE_LIST.for_dropdown, - tooltip=("nur wenn Inland angegeben"), ), FormField( "Land", @@ -535,13 +530,11 @@ _INITREC_MASTER_DATA = [ FormFieldType.TEXT, key="familienstand", required=False, - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", ), FormField( "Anzahl Kinder", FormFieldType.DYNAMIC_DROPDOWN_NUMERIC, required=False, - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl_kinder", children=[ FormField( @@ -549,7 +542,6 @@ _INITREC_MASTER_DATA = [ FormFieldType.DROPDOWN, required=False, options=[(str(x), None) for x in range(11)], - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl", children=[ FormField("Alter Kind", FormFieldType.TEXT, key="alter"), @@ -593,7 +585,6 @@ _INITREC_ADDITIONAL_DATA = [ ("Chancenaufenthaltsrecht §104c AufenthG", None), ("Sonstiges", None), ], - tooltip="sofern nicht bekannt, unbedingt einfordern", ), FormField( "Gültigkeit Aufenthaltsstatus", @@ -685,10 +676,6 @@ _INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_abschlussgrad", - tooltip=( - "bitte den Titel eingeben z.B. Doktor, Diplom oder " - "Betriebswirt (Fachschulabschluss)" - ), ), FormField( "Abschlussgrad laut Dokument", @@ -707,10 +694,6 @@ _INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_beruf", - tooltip=( - "bitte spezifizieren z.B. Allgemeinmedizin, Ingenieur Maschinenbau, " - "technischer Betriebswirt Datenverarbeitung" - ), ), FormField( "Land", @@ -737,7 +720,6 @@ _INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_bemerkungsfeld", - tooltip="z.B. Promotionen oder den Studiengang angeben", ), ] @@ -824,7 +806,6 @@ _INITREC_WORK_EXPERIENCE = [ ("Teilzeit", None), ("Sonstiges", None), ], - tooltip="Minijob, Praktikum, Wehrdienst, soziale Dienste", ), FormField( "Bemerkungsfeld", @@ -889,42 +870,6 @@ _INITREC_LANGUAGES = [ INITREC_COMP = [ - # FormField( - # "Ersteintrag Datum", - # FormFieldType.TEXT_DATETIME, - # required=False, - # key="Metadaten_erstellung", - # readonly=True, - # ignore_get_data=True, - # ), - # FormField( - # "Aktualisierung Datum", - # FormFieldType.TEXT_DATETIME, - # required=False, - # key="Metadaten_aktualisierung", - # readonly=True, - # ignore_get_data=True, - # ), - # FormField( - # "Aktualisierung Nutzer", - # FormFieldType.TEXT, - # required=False, - # key="Metadaten_nutzer", - # readonly=True, - # ignore_get_data=True, - # ), - # FormField( - # "Fallnummer", - # FormFieldType.TEXT, - # required=True, - # key="Grunderfassung_fallnummer", - # ), - # FormField( - # "Notizen", - # FormFieldType.LONGTEXT, - # required=False, - # key="Grunderfassung_notiz", - # ), FormField( "Suche", FormFieldType.CUSTOM, @@ -981,49 +926,6 @@ INITREC_METADATEN = [ ] INITREC_PERSON = [ - # FormField( - # "Ersteintrag Datum", - # FormFieldType.TEXT_DATETIME, - # required=False, - # key="Metadaten_erstellung", - # readonly=True, - # ignore_get_data=True, - # ), - # FormField( - # "Aktualisierung Datum", - # FormFieldType.TEXT_DATETIME, - # required=False, - # key="Metadaten_aktualisierung", - # readonly=True, - # ignore_get_data=True, - # ), - # FormField( - # "Aktualisierung Nutzer", - # FormFieldType.TEXT, - # required=False, - # key="Metadaten_nutzer", - # readonly=True, - # ignore_get_data=False, - # ), - # FormField( - # "Wiedereintrittsdatum", - # FormFieldType.DATE, - # required=False, - # key="Metadaten_wiedereintrittsdatum", - # readonly=False, - # ), - # FormField( - # "Fallnummer", - # FormFieldType.TEXT, - # required=True, - # key="Grunderfassung_fallnummer", - # ), - # FormField( - # "Notizen", - # FormFieldType.LONGTEXT, - # required=False, - # key="Grunderfassung_notiz", - # ), FormField( "Status && Projektrelevanz", FormFieldType.GROUP, diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index e3cf0cb..75e9bad 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -1142,15 +1142,15 @@ class Grunderfassung_SuchWidget(CustomFormWidget): self.person_search_input = cast(QComboBox, lookup[0]["widget"]) self.person_search_input.currentIndexChanged.connect(self._selected_person) - self.CHANNEL_AWARENESS_KEY = "kanal_aufmerksamkeit" - lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_AWARENESS_KEY) + self.CHANNEL_CONTACT_KEY = "kontaktweg" + lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_CONTACT_KEY) assert len(lookup) == 1 - self.channel_awareness = cast(QComboBox, lookup[0]["widget"]) + self.channel_contact = cast(QComboBox, lookup[0]["widget"]) self.DATA_EXPORT_FIELDS = ( self.COMPANY_SEARCH_INPUT_KEY, self.PERSON_SEARCH_INPUT_KEY, - self.CHANNEL_AWARENESS_KEY, + self.CHANNEL_CONTACT_KEY, ) self.company_widgets: dict[str, QWidget] = {} @@ -1234,7 +1234,7 @@ class Grunderfassung_SuchWidget(CustomFormWidget): def _clear_company_fields(self) -> None: self.person_search_input.clear() - self.channel_awareness.setCurrentIndex(0) + self.channel_contact.setCurrentIndex(0) for widget in self.company_widgets.values(): widget = cast(QLineEdit, widget) widget.clear() @@ -3473,8 +3473,6 @@ class Page_InitRec(QWidget): # TODO saving logic to connect company and person if needed def _reset(self) -> None: - # TODO change to make these calls implicit in child modules via - # TODO staes for form in self.STATE.child_modules: form.reset_form() self.STATE.ids = EntityIds() @@ -3494,9 +3492,9 @@ class Page_InitRec(QWidget): pers_id=5, ) - # ids = EntityIds( - # link_id=7, - # ) + ids = EntityIds( + link_id=7, + ) self.STATE.ids.update(ids, ignore_none=False) if ids.empty(): @@ -3752,7 +3750,6 @@ class Page_InitRec(QWidget): try: logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...") - # validated_data = self.cfg.pydantic_model(**form_data) for ent_type, state in self.STATE.initrec_form_states.items(): logger_page_initrec.debug( -- 2.34.1 From 2894d3a1a9c8da7a213e75c4aadf96bec023218f Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 28 Jul 2026 09:18:47 +0200 Subject: [PATCH 61/74] fulfil customer requirements REQ 1011, test for REQ 1013, 1014 --- src/wce_crm/form_defs.py | 8 ++++---- src/wce_crm/gui.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index aa476d5..c0da687 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -434,7 +434,7 @@ _INITREC_MASTER_DATA = [ ), FormField( "Geburtsdatum", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, key="geburtsdatum", required=False, ), @@ -588,7 +588,7 @@ _INITREC_ADDITIONAL_DATA = [ ), FormField( "Gültigkeit Aufenthaltsstatus", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="WI_gueltigkeit_aufenthaltstitel", ), @@ -858,7 +858,7 @@ _INITREC_LANGUAGES = [ ), FormField( "Datum des Nachweises", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="SP_datum_nachweis", ), @@ -906,7 +906,7 @@ INITREC_METADATEN = [ ), FormField( "Wiedereintrittsdatum", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="Metadaten_wiedereintrittsdatum", readonly=False, diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 75e9bad..b8204a5 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -862,13 +862,13 @@ def set_widget_value( raise ValueError(f"Could not parse date field value >{value}<") widget.setDate(set_date) elif isinstance(widget, FlexibleDateInput): - assert isinstance(value, datetime.datetime) + assert isinstance(value, (datetime.datetime, datetime.date)) or value is None try: widget.set_pydate(value, raise_error=True) except ValueError: raise ValueError(f"Could not parse date field value >{value}<") elif isinstance(widget, FlexibleDateTimeInput): - assert isinstance(value, datetime.datetime) + assert isinstance(value, datetime.datetime) or value is None try: widget.set_pydatetime(value, raise_error=True) except ValueError: @@ -2005,9 +2005,13 @@ class FlexibleDateInput(QWidget): def set_pydate( self, - date: datetime.datetime, + date: datetime.datetime | datetime.date | None, raise_error: bool = False, ) -> None: + if date is None: + self.reset() + return + date_to_set = QDate(date.year, date.month, date.day) if raise_error and not date_to_set.isValid(): raise ValueError("Could not parse date object correctly") @@ -2224,9 +2228,13 @@ class FlexibleDateTimeInput(QWidget): def set_pydatetime( self, - dt: datetime.datetime, + dt: datetime.datetime | None, raise_error: bool = False, ) -> None: + if dt is None: + self.reset() + return + dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0) if raise_error and not dt_to_set.isValid(): raise ValueError("Could not parse datetime object correctly") @@ -3277,7 +3285,7 @@ class Page_InitRec(QWidget): print(f">>>> {pformat(search_res)}") assert len(search_res) == 1 field_wiedereintrittsdatum = search_res[0]["widget"] - assert isinstance(field_wiedereintrittsdatum, QDateEdit) + assert isinstance(field_wiedereintrittsdatum, FlexibleDateInput) self.field_wiedereintrittsdatum = field_wiedereintrittsdatum # ** person -- 2.34.1 From 07c2bd92daa4dec8f370ba824034713eab17fc8d Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 28 Jul 2026 09:45:28 +0200 Subject: [PATCH 62/74] adaptive validation for initial recording page --- src/wce_crm/gui.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index b8204a5..1fc8a6e 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3500,9 +3500,9 @@ class Page_InitRec(QWidget): pers_id=5, ) - ids = EntityIds( - link_id=7, - ) + # ids = EntityIds( + # link_id=7, + # ) self.STATE.ids.update(ids, ignore_none=False) if ids.empty(): @@ -3688,6 +3688,12 @@ class Page_InitRec(QWidget): def validate(self) -> list[str]: errors: list[str] = [] for m in self.STATE.child_modules: + if ( + m.STATE.ent_type is EntityType.COMPANY + and self.STATE.recording_type is not RecordingType.WITH_COMPANY + ): + continue + errors.extend(m.validate()) return errors -- 2.34.1 From 02d94c4a80e13e967bcb5ce05af22a0c3c1e46ab Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 28 Jul 2026 12:14:51 +0200 Subject: [PATCH 63/74] adapted linking dialog for consulting sessions --- prototypes/tests.py | 80 +++++++++- src/wce_crm/backend/backend.py | 98 +++++------- src/wce_crm/data_models.py | 70 +-------- src/wce_crm/gui.py | 274 +++++++++++++++------------------ src/wce_crm/logging.py | 2 +- src/wce_crm/types.py | 1 + 6 files changed, 247 insertions(+), 278 deletions(-) diff --git a/prototypes/tests.py b/prototypes/tests.py index b6ee6a0..5894c3e 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -8,12 +8,12 @@ import json import re from collections import defaultdict from collections.abc import Sequence -from pprint import pprint +from pprint import pformat, pprint from typing import Annotated, Any import babel import polars as pl -import sqlalchemy as sql +import sqlalchemy as sa from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator from PySide6.QtCore import QDate, Qt @@ -22,11 +22,81 @@ from wce_crm.backend import backend from wce_crm.types import EntityIds, RecordingType # %% -empty = tuple() +# backend.page_consulting_linking_companies(None) +# backend.page_consulting_linking_persons(3) -# not any(empty) -all(empty) +# %% +@dc.dataclass(slots=True, kw_only=True) +class LinkingPerson: + pers_id: int + display_name: str + un_ids: set[int] + + +with db.ENGINE.connect() as conn: + # if un_id None: list all persons + # else: list just the persons associated with the company + + stmt = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ) + + # if un_id is not None: + # person_select = person_select.select_from( + # db.t_grunderfassung_personen.join( + # db.t_zuordnung_personen_unternehmen, + # db.t_grunderfassung_personen.c.pers_id + # == db.t_zuordnung_personen_unternehmen.c.pers_id, + # ) + # ).where(db.t_zuordnung_personen_unternehmen.c.un_id == un_id) + + ret = conn.execute(stmt) + + persons_map: dict[str, Any] = {} + for row in ret: + p_id = row.pers_id + + if p_id not in persons_map: + first_name = row.Stammdaten__vorname + last_name = row.Stammdaten__name + location_city = row.Stammdaten__ort + + display_name = f"{first_name} {last_name}" + if location_city: + display_name += f" (Ort: {location_city})" + + persons_map[p_id] = { + "pers_id": p_id, + "display_name": display_name, + "un_ids": set(), + } + + # Falls verknüpft, ID zur Menge hinzufügen + if row.un_id is not None: + persons_map[p_id]["un_ids"].add(row.un_id) + + person_names = [LinkingPerson(**data) for data in persons_map.values()] + + +# person_names = ret.mappings().all() +# if not person_names: +# print(f"No persons found for 'un_id': {un_id}") + +print(f"[Page Consulting -- Linking] Retrieved person names:\n{pformat(person_names)}") + # %% dic = { "t1": "test1", diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 228e51e..e412388 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -872,23 +872,18 @@ def page_consulting_from_db( # // consulting page interaction -# TODO change to correct logic def page_consulting_linking_companies( un_id: RecId | None, ) -> list[LinkingConsultationsEntryCompany]: - raise RuntimeError("TODOs to implement") - with db.ENGINE.connect() as conn: q_relevant_companies = sa.select( db.t_unternehmen.c.un_id, - db.t_unternehmen.c.Partnersuche__un_suche, ).where( db.t_unternehmen.c.geloescht.is_(None), - db.t_unternehmen.c.Partnersuche__un_suche.is_not(None), ) if un_id is not None: q_relevant_companies = q_relevant_companies.where( - db.grunderfassung_unternehmen.c.un_id == un_id + db.t_unternehmen.c.un_id == un_id ) q_relevant_companies = q_relevant_companies.subquery("relevant_companies") @@ -899,8 +894,7 @@ def page_consulting_linking_companies( ).select_from( q_relevant_companies.join( db.t_ext_crm_master_attach, - q_relevant_companies.c.Partnersuche__un_suche - == db.t_ext_crm_master_attach.c.ma_id, + q_relevant_companies.c.un_id == db.t_ext_crm_master_attach.c.ma_id, ) ) @@ -923,57 +917,49 @@ def page_consulting_linking_companies( return linking_entries -def page_consulting_linking_persons( - un_id: RecId | None, -) -> list[LinkingConsultationsEntryPerson]: - raise RuntimeError("TODOs to implement") - +def page_consulting_linking_persons() -> list[LinkingConsultationsEntryPerson]: with db.ENGINE.connect() as conn: - # if un_id None: list all persons - # else: list just the persons associated with the company - - person_select = sa.select( - db.t_grunderfassung_personen.c.pers_id, - db.t_grunderfassung_personen.c.Stammdaten__name, - db.t_grunderfassung_personen.c.Stammdaten__vorname, - db.t_grunderfassung_personen.c.Stammdaten__ort, - ) - - if un_id is not None: - person_select = person_select.select_from( - db.t_grunderfassung_personen.join( - db.t_zuordnung_personen_unternehmen, - db.t_grunderfassung_personen.c.pers_id - == db.t_zuordnung_personen_unternehmen.c.pers_id, - ) - ).where(db.t_zuordnung_personen_unternehmen.c.un_id == un_id) - - ret = conn.execute(person_select) - person_names = ret.mappings().all() - if not person_names: - logger.warning(f"No persons found for 'un_id': {un_id}") - return [] - - logger.debug("[Page Consulting -- Linking] Retrieved person names:\n%s", person_names) - - linking_entries: list[LinkingConsultationsEntryPerson] = [] - for entry in person_names: - first_name = entry["Stammdaten__vorname"] - last_name = entry["Stammdaten__name"] - location_city = entry["Stammdaten__ort"] - - display_name = f"{first_name} {last_name}" - if location_city: - display_name += f" (Ort: {location_city})" - - linking_entries.append( - LinkingConsultationsEntryPerson( - pers_id=entry["pers_id"], - display_name=display_name, + stmt = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) ) - return linking_entries + ret = conn.execute(stmt) + + persons_map: dict[str, LinkingConsultationsEntryPerson] = {} + for row in ret: + p_id = row.pers_id + + if p_id not in persons_map: + first_name = row.Stammdaten__vorname + last_name = row.Stammdaten__name + location_city = row.Stammdaten__ort + + display_name = f"{first_name} {last_name}" + if location_city: + display_name += f" (Ort: {location_city})" + + persons_map[p_id] = LinkingConsultationsEntryPerson( + pers_id=p_id, + un_ids=set(), + display_name=display_name, + ) + + if row.un_id is not None: + persons_map[p_id].un_ids.add(row.un_id) + + return list(persons_map.values()) # TODO wrap result @@ -982,8 +968,6 @@ def page_consulting_eastablish_link( un_id: RecId | None, pers_id: RecId | None, ) -> None: - raise RuntimeError("TODOs to implement") - stmt = ( sa.update(db.t_beratung_vorgang) .where(db.t_beratung_vorgang.c.vorgang_id == vorgang_id) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 922c425..6ce0bf0 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -124,36 +124,6 @@ class Module(WrapperModule, Protocol): def save_data(self) -> None: ... -# TODO check removal -# class AutoFormInsert(Protocol): -# def __call__( -# self, -# data: dict[str, Any], -# ) -> RecId: ... - - -# class AutoFormUpdate(Protocol): -# def __call__( -# self, -# id_: int, -# data: dict[str, Any], -# ) -> None: ... - - -# class AutoFormGet(Protocol): -# def __call__( -# self, -# id_: int, -# ) -> dict[str, Any]: ... - - -# class AutoFormDelete(Protocol): -# def __call__( -# self, -# id_: int, -# ) -> None: ... - - class InitRecFormToDb(Protocol): def __call__( self, @@ -222,24 +192,6 @@ class Page_NewEntry_State(PageState[Module]): session: Session -# TODO check removal -# INITREC_REC_TO_ENTITIES: Final[dict[RecordingType, frozenset[EntityType]]] = { -# RecordingType.WITH_COMPANY: frozenset( -# ( -# EntityType.METADATA, -# EntityType.COMPANY, -# EntityType.PERSON, -# ) -# ), -# RecordingType.WITHOUT_COMPANY: frozenset( -# ( -# EntityType.METADATA, -# EntityType.PERSON, -# ) -# ), -# } - - @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_Form_Data: metadata: dict[str, Any] @@ -260,11 +212,8 @@ class Page_InitRec_State(PageState["InitRecForm"]): initrec_form_states: dict[EntityType, Page_InitRec_Form_State] = dc.field( init=False, default_factory=dict ) - # pydantic_models: dict[RecordingType, type[FlatBaseModel]] = dc.field(default_factory=dict) def __post_init__(self) -> None: - # TODO check removal - # relevant_entities = INITREC_REC_TO_ENTITIES[self.recording_type] relevant_entities = frozenset( ( EntityType.METADATA, @@ -306,24 +255,6 @@ class Page_InitRec_Form_State(PageState[Module]): geloescht: bool = False -# TODO remove -# @dc.dataclass(slots=True, kw_only=True) -# class Page_InitRecCompany_State(PageState[Module]): -# session: Session -# ids: EntityIds -# # un_id: RecId | None = None -# locked: bool - - -# @dc.dataclass(slots=True, kw_only=True) -# class Page_InitRecPerson_State(PageState[Module]): -# session: Session -# ids: EntityIds -# # pers_id: RecId | None = None -# # un_id: RecId | None = None -# locked: bool - - @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -345,6 +276,7 @@ class Page_Consulting_Linking_State(PageState[Module]): un_id: RecId | None = None pers_id: RecId | None = None beratungs_typ: ConsultingType + filter_un_ids: set[RecId] | None = None @dc.dataclass(slots=True, kw_only=True) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 1fc8a6e..572571f 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -2907,9 +2907,9 @@ class Page_NewEntry(QWidget): back_requested = Signal() # Signal back button # TODO re-enable with correct settings - # company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen" - # person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson" - consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" + # company_requested = Signal(Page_InitRec_State) # Signal "Unternehmen" + # person_requested = Signal(Page_InitRec_State) # Signal "Individualperson" + new_consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" new_initrec_requested = Signal(Page_InitRec_State) # TODO: rename def __init__( @@ -2919,75 +2919,101 @@ class Page_NewEntry(QWidget): super().__init__() self.STATE = state - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + outer_layout = QHBoxLayout(self) + layout_container = QWidget() + layout_container.setMinimumWidth(700) + layout_container.setMaximumWidth(1000) + layout = QVBoxLayout(layout_container) + + outer_layout.addStretch(1) + outer_layout.addWidget(layout_container, stretch=100) + # outer_layout.addWidget(scroll_area, stretch=100) + outer_layout.addStretch(1) + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Zurück-Button back_btn = QPushButton("← Zurück zur Übersicht") back_btn.setFixedWidth(200) back_btn.clicked.connect(lambda: self.back_requested.emit()) - # Platzhalter für die Details self.title_label = QLabel("Wählen Sie den Typ der Grunderfassung") self.title_label.setStyleSheet( "font-size: 24px; font-weight: bold; margin-top: 20px;" ) + title_initrec = QLabel("Grunderfassung anlegen") + title_initrec.setStyleSheet( + "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" + ) btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) btn_company.clicked.connect(self._request_initrec_company) - btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) btn_person.clicked.connect(self._request_initrec_person) + title_consulting = QLabel("Beratung durchführen") + title_consulting.setStyleSheet( + "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" + ) btn_consulting = QPushButton("Beratungsgespräch →") btn_consulting.setFixedWidth(300) btn_consulting.setFixedHeight(40) btn_consulting.clicked.connect(self._request_consulting) - btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →") - btn_newinitrec.setFixedWidth(300) - btn_newinitrec.setFixedHeight(40) - btn_newinitrec.clicked.connect(self._request_new_initrec) + # TODO check removal + # btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →") + # btn_newinitrec.setFixedWidth(300) + # btn_newinitrec.setFixedHeight(40) + # btn_newinitrec.clicked.connect(self._debug_request_new_initrec) layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) - layout.addSpacing(20) - layout.addWidget(btn_newinitrec) - layout.addSpacing(20) + # layout.addSpacing(20) + # layout.addWidget(btn_newinitrec) + # layout.addSpacing(20) + layout.addWidget(title_initrec) layout.addWidget(btn_company) layout.addWidget(btn_person) layout.addSpacing(10) + layout.addWidget(title_consulting) layout.addWidget(btn_consulting) # TODO configure correctly def _request_initrec_company(self) -> None: logger_gui.debug("[Page -- NewEntry] Call for company...") - # ids = EntityIds(type=RecordingType.COMPANY) - # req_state = Page_InitRecCompany_State( - # session=self.STATE.session, - # ids=ids, - # # un_id=None, # TODO remove - # locked=False, - # ) - # logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) - # self.company_requested.emit(req_state) + logger_gui.debug("[Page -- NewEntry] Call for person...") + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) def _request_initrec_person(self) -> None: logger_gui.debug("[Page -- NewEntry] Call for person...") - # ids = EntityIds(type=RecordingType.PERSON) - # req_state = Page_InitRecPerson_State( - # session=self.STATE.session, - # ids=ids, - # # pers_id=None, # TODO remove - # locked=False, - # ) - # logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) - # self.person_requested.emit(req_state) + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITHOUT_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) + + def _debug_request_new_initrec(self) -> None: + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) def _request_consulting(self) -> None: req_state = Page_Consulting_State( @@ -2997,17 +3023,7 @@ class Page_NewEntry(QWidget): locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) - self.consulting_requested.emit(req_state) - - def _request_new_initrec(self) -> None: - req_state = Page_InitRec_State( - session=self.STATE.session, - recording_type=RecordingType.WITH_COMPANY, - form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), - locked=False, - ) - logger_gui.debug("[Page -- NewInitRec] State to call (IDs): %s", req_state.ids) - self.new_initrec_requested.emit(req_state) + self.new_consulting_requested.emit(req_state) def _sync_state_to_GUI(self) -> None: pass @@ -5177,46 +5193,14 @@ class Page_Consulting_LinkingDialog(QDialog): parent: QWidget | None = None, ): super().__init__(parent) + # State and data self.STATE = state + self.all_companies = backend.page_consulting_linking_companies(None) + self.all_persons = backend.page_consulting_linking_persons() self.setWindowTitle("Beziehung verknüpfen") self.resize(700, 450) - # firmen_aus_db = [{"id": 1, "name": "Siemens AG"}, {"id": 2, "name": "Bosch GmbH"}] - # personen_aus_db = [ - # { - # "id": 50, - # "name": "Max Mustermann", - # "unternehmen_id": 1, - # "firma_name": "Siemens AG", - # }, - # { - # "id": 51, - # "name": "Erika Muster", - # "unternehmen_id": 1, - # "firma_name": "Siemens AG", - # }, - # { - # "id": 52, - # "name": "Peter Müller", - # "unternehmen_id": 2, - # "firma_name": "Bosch GmbH", - # }, - # { - # "id": 53, - # "name": "Freiberufler Frank", - # "unternehmen_id": None, - # "firma_name": None, - # }, - # ] - - # self.all_unternehmen = firmen_aus_db - # self.all_personen = personen_aus_db - - # # Rückgabewerte - # self.gewaehltes_unternehmen = None - # self.gewaehlte_person = None - # --- UI LAYOUT --- main_layout = QVBoxLayout(self) column_layout = QHBoxLayout() @@ -5241,7 +5225,7 @@ class Page_Consulting_LinkingDialog(QDialog): column_layout.addLayout(self.vbox_persons) main_layout.addLayout(column_layout) - # Unten: OK / Abbrechen Buttons + # OK / Cancel buttons self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) @@ -5251,10 +5235,7 @@ class Page_Consulting_LinkingDialog(QDialog): self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen") main_layout.addWidget(self.button_box) - # initial loading - # self._load_companies() - # self._load_persons() - + # TODO check removal # list_style = """ # QListWidget::item:selected { # background-color: #0078D4; /* Blau, wenn die Liste aktiv fokussiert ist */ @@ -5305,20 +5286,28 @@ class Page_Consulting_LinkingDialog(QDialog): def _sync_state_to_GUI( self, reset: bool, - section: Literal["person_selection", "validation"] | None = None, + section: Literal["person_selection", "company_selection", "validation"] | None = None, ) -> None: if reset: self._reset() - self._load_companies() + self._load_companies_to_list(None) + self._load_persons_to_list(None) if section is None or section == "person_selection": if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: self._enable_person_selection(False) elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: self._enable_person_selection(True) - self.list_persons.blockSignals(True) - self._load_persons(self.STATE.un_id) - self.list_persons.blockSignals(False) + if not self.list_persons.selectedItems(): + self.list_persons.blockSignals(True) + self._load_persons_to_list(self.STATE.un_id) + self.list_persons.blockSignals(False) + + if section is None or section == "company_selection": + if not self.list_companies.selectedItems(): + self.list_companies.blockSignals(True) + self._load_companies_to_list(self.STATE.filter_un_ids) + self.list_companies.blockSignals(False) if section is None or section == "validation": self._validate() @@ -5341,31 +5330,49 @@ class Page_Consulting_LinkingDialog(QDialog): else: self.ok_btn.setEnabled(False) - def _load_companies(self) -> None: + def _reload_all_companies(self) -> None: + self.all_companies = backend.page_consulting_linking_companies(None) + + def _reload_all_persons(self) -> None: + self.all_persons = backend.page_consulting_linking_persons() + + def _load_companies_to_list( + self, + un_ids: set[RecId] | None, + ) -> None: + # search_text = self.suche_firma.text().strip().lower() self.list_companies.clear() - companies = backend.page_consulting_linking_companies(un_id=self.STATE.un_id) + # TODO check reload of companies + # companies = backend.page_consulting_linking_companies(None) logger_page_consulting.debug( - "[Page Consulting -- Linking] Retrieved company entries:\n%s", companies + "[Page Consulting -- Linking] Retrieved company entries:\n%s", self.all_companies ) - for company in companies: + for company in self.all_companies: + if un_ids and company.un_id not in un_ids: + continue + item = QListWidgetItem(company.display_name) item.setData(Qt.ItemDataRole.UserRole, company) self.list_companies.addItem(item) - def _load_persons( + def _load_persons_to_list( self, un_id: RecId | None, ) -> None: """Lädt Personen. Wenn firma_id übergeben wird, wird gefiltert.""" self.list_persons.clear() - persons = backend.page_consulting_linking_persons(un_id) + # TODO check reload of persons + # persons = backend.page_consulting_linking_persons() logger_page_consulting.debug( - "[Page Consulting -- Linking] Retrieved person entries:\n%s", persons + "[Page Consulting -- Linking] Retrieved person entries:\n%s", self.all_persons ) - for person in persons: + for person in self.all_persons: + if un_id and un_id not in person.un_ids: + continue + item = QListWidgetItem(person.display_name) item.setData(Qt.ItemDataRole.UserRole, person) self.list_persons.addItem(item) @@ -5386,10 +5393,6 @@ class Page_Consulting_LinkingDialog(QDialog): pformat(self.STATE), ) self._sync_state_to_GUI(reset=False) - # if self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: - # self.list_persons.blockSignals(True) - # self._load_persons(un_id=self.STATE.un_id) - # self.list_persons.blockSignals(False) def _person_selected(self) -> None: items = self.list_persons.selectedItems() @@ -5400,29 +5403,29 @@ class Page_Consulting_LinkingDialog(QDialog): "LinkingConsultationsEntryPerson", items[0].data(Qt.ItemDataRole.UserRole) ) self.STATE.pers_id = person.pers_id - self._sync_state_to_GUI(reset=False, section="validation") - # TODO this backlink is a really good idea and should be implemented - # if person["unternehmen_id"]: - # self.list_companies.blockSignals(True) - # - # for i in range(self.list_companies.count()): - # item = self.list_companies.item(i) - # firma_data = item.data(Qt.ItemDataRole.UserRole) - # if firma_data["id"] == person["unternehmen_id"]: - # item.setSelected(True) - # self.gewaehltes_unternehmen = firma_data - # break - # self.list_companies.blockSignals(False) + if person.un_ids: + self.STATE.filter_un_ids = person.un_ids + else: + self.STATE.filter_un_ids = None + + self._sync_state_to_GUI(reset=False) def _clear_company_selection(self) -> None: + logger_page_consulting.debug( + "[Page Consulting -- Linking] Clear company selection...", + ) self.list_companies.clearSelection() self.STATE.un_id = None self._sync_state_to_GUI(reset=False) def _clear_person_selection(self) -> None: + logger_page_consulting.debug( + "[Page Consulting -- Linking] Clear person selection...", + ) self.list_persons.clearSelection() self.STATE.pers_id = None + self.STATE.filter_un_ids = None self._sync_state_to_GUI(reset=False) def get_state(self) -> Page_Consulting_Linking_State: @@ -6220,8 +6223,8 @@ class MainWindow(QMainWindow): self.new_entry_select.back_requested.connect(self.show_main_page) # self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) # self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) - self.new_entry_select.consulting_requested.connect(self.show_page_consulting) - self.new_entry_select.new_initrec_requested.connect(self.show_page_newinitrec) + self.new_entry_select.new_consulting_requested.connect(self.show_page_consulting) + self.new_entry_select.new_initrec_requested.connect(self.show_page_initrec) self.stack.addWidget(self.new_entry_select) # SITE: 'Grunderfassung' @@ -6232,35 +6235,13 @@ class MainWindow(QMainWindow): form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) - self.newinitrec = Page_InitRec(newinitrec_state) - self.newinitrec.back_main_requested.connect(self.show_main_page) - self.newinitrec.back_requested.connect(self.simple_go_back) - self.newinitrec.company_profile_requested.connect(self.show_page_company_profile) + self.initrec = Page_InitRec(newinitrec_state) + self.initrec.back_main_requested.connect(self.show_main_page) + self.initrec.back_requested.connect(self.simple_go_back) + self.initrec.company_profile_requested.connect(self.show_page_company_profile) # self.newinitrec.update_triggered.connect(self.update_grid) - self.stack.addWidget(self.newinitrec) + self.stack.addWidget(self.initrec) - # SITE: 'Grunderfassung Unternehmen' - # ids = EntityIds(type=RecordingType.COMPANY) - # initrec_company_state = Page_InitRecCompany_State( - # session=self.STATE.session, ids=ids, locked=False - # ) - # self.initrec_company = Page_InitRecCompany(initrec_company_state) - # self.initrec_company.back_main_requested.connect(self.show_main_page) - # self.initrec_company.back_requested.connect(self.simple_go_back) - # self.initrec_company.update_main_page.connect(self.update_grid) - # self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person) - # self.initrec_company.company_profile_requested.connect(self.show_page_company_profile) - # self.stack.addWidget(self.initrec_company) - # SITE: 'Grunderfassung Person' - # ids = EntityIds(type=RecordingType.PERSON) - # initrec_person_state = Page_InitRecPerson_State( - # session=self.STATE.session, ids=ids, locked=False - # ) - # self.initrec_person = Page_InitRecPerson(initrec_person_state) - # self.initrec_person.back_main_requested.connect(self.show_main_page) - # self.initrec_person.back_requested.connect(self.simple_go_back) - # self.initrec_person.update_triggered.connect(self.update_grid) - # self.stack.addWidget(self.initrec_person) # SITE: 'Unternehmensprofil' comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) self.company_profile = Page_CompanyProfile(comp_profile_state) @@ -6270,6 +6251,7 @@ class MainWindow(QMainWindow): self.company_profile.back_requested.connect(self.simple_go_back) self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) + # SITE: 'Beratungsgespräch' self.consulting_page = Page_Consulting(self.STATE.session) self.consulting_page.back_main_requested.connect(self.show_main_page) @@ -6452,12 +6434,12 @@ class MainWindow(QMainWindow): def show_new_entry_select(self): self._navigate_to_target(self.new_entry_select) - def show_page_newinitrec( + def show_page_initrec( self, new_state: Page_InitRec_State, ): - self.newinitrec.load_state(new_state) - self._navigate_to_target(self.newinitrec) + self.initrec.load_state(new_state) + self._navigate_to_target(self.initrec) # def show_page_initrec_company( # self, diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index d011a09..7c12017 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -41,7 +41,7 @@ logger_custom_fields = logger_gui.getChild("custom_fields") logger_custom_fields.setLevel(logging.WARNING) logger_page_consulting = logger_gui.getChild("page_consulting") -logger_page_consulting.setLevel(logging.WARNING) +logger_page_consulting.setLevel(logging.DEBUG) logger_page_initrec = logger_gui.getChild("page_initrec") logger_page_initrec.setLevel(logging.DEBUG) diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 45d2a17..8fe1d55 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -273,4 +273,5 @@ class LinkingConsultationsEntryCompany: @dc.dataclass(slots=True) class LinkingConsultationsEntryPerson: pers_id: RecId + un_ids: set[RecId] display_name: str -- 2.34.1 From b635e7eb750f3f1a2db59eff46ed5bb77560ebe8 Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 28 Jul 2026 19:02:24 +0200 Subject: [PATCH 64/74] updated main page interaction --- prototypes/tests.py | 117 ++++++++- src/wce_crm/backend/backend.py | 453 +++++---------------------------- src/wce_crm/data_models.py | 12 +- src/wce_crm/gui.py | 98 +++---- src/wce_crm/types.py | 6 +- 5 files changed, 232 insertions(+), 454 deletions(-) diff --git a/prototypes/tests.py b/prototypes/tests.py index 5894c3e..46d3b72 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -19,13 +19,122 @@ from PySide6.QtCore import QDate, Qt from wce_crm import constants, db from wce_crm.backend import backend -from wce_crm.types import EntityIds, RecordingType +from wce_crm.types import EntityIds, MainPageEntry, RecordingType # %% # backend.page_consulting_linking_companies(None) # backend.page_consulting_linking_persons(3) +ret = backend.main_page_get_entries() +pprint(ret) +# %% +with db.ENGINE.connect() as conn: + q_relevant_entries = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.aktualisiert, + db.t_zuordnung_personen_unternehmen.c.id.label("link_id"), + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ).subquery("relevant_entries") + + stmt = sa.select( + q_relevant_entries, + db.t_ext_crm_master_attach.c.ma_unternehmensname, + ).outerjoin( + db.t_ext_crm_master_attach, + q_relevant_entries.c.un_id == db.t_ext_crm_master_attach.c.ma_id, + ) + + ret = conn.execute(stmt) + + # results = list(ret.mappings()) + # print(f"Results are:\n{pformat(results)}") + + main_page_entries: list[MainPageEntry] = [] + for entry in ret.mappings(): + ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], + ) + recording_type = RecordingType.WITHOUT_COMPANY + if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + + first_name = entry["Stammdaten__vorname"] + last_name = entry["Stammdaten__name"] + location_city = entry["Stammdaten__ort"] + person_name = f"{first_name} {last_name}" + if location_city: + person_name += f" (Ort: {location_city})" + + company_name = entry["ma_unternehmensname"] + + display_name = person_name + if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + + main_page_entries.append( + MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, + ) + ) + main_page_entries.sort(key=lambda x: x.dt_updated, reverse=True) + pprint(main_page_entries) +# %% +# %% +entry = results[0] +ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], +) +recording_type = RecordingType.WITHOUT_COMPANY +if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + +first_name = entry["Stammdaten__vorname"] +last_name = entry["Stammdaten__name"] +location_city = entry["Stammdaten__ort"] +person_name = f"{first_name} {last_name}" +if location_city: + person_name += f" (Ort: {location_city})" + +company_name = entry["ma_unternehmensname"] + +display_name = person_name +if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + +main_page_entry = MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, +) + +# %% +pprint(main_page_entry) + + +# %% # %% @dc.dataclass(slots=True, kw_only=True) class LinkingPerson: @@ -74,13 +183,13 @@ with db.ENGINE.connect() as conn: last_name = row.Stammdaten__name location_city = row.Stammdaten__ort - display_name = f"{first_name} {last_name}" + person_name = f"{first_name} {last_name}" if location_city: - display_name += f" (Ort: {location_city})" + person_name += f" (Ort: {location_city})" persons_map[p_id] = { "pers_id": p_id, - "display_name": display_name, + "display_name": person_name, "un_ids": set(), } diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index e412388..97a0042 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -134,232 +134,6 @@ def initrec_comp_contact_person_search_get_info( # // internals -# TODO check removal -# def initrec_company_insert_initial_recording( -# data: dict[str, Any], -# ) -> RecId: -# logger.debug("[Call backend] insert_initial_recording") -# stmt = db.grunderfassung_unternehmen.insert() -# with db.ENGINE.begin() as conn: -# ret = conn.execute(stmt, data) - -# if ret.rowcount == 0: -# raise IOError("Entry was not inserted correctly") - -# prim_keys = ret.inserted_primary_key -# assert prim_keys - -# return prim_keys[0] - - -# def initrec_company_update_initial_recording( -# id_: RecId, -# data: dict[str, Any], -# ) -> None: -# logger.debug("[Call backend] update_initial_recording") -# stmt = db.grunderfassung_unternehmen.update().where( -# db.grunderfassung_unternehmen.c.un_id == id_ -# ) -# with db.ENGINE.begin() as conn: -# conn.execute(stmt, data) - - -# def initrec_company_to_db( -# auto_form_data: InitRec, -# ) -> InitRec: -# logger.debug("[AutoForm -- backend] Call database saving routine...") - -# dump_data = copy.deepcopy(auto_form_data.db_data) -# dump_data["geloescht"] = auto_form_data.geloescht - -# with db.ENGINE.begin() as conn: -# if auto_form_data.ids.un_id is None: -# logger.debug("[AutoForm -- backend] Insert...") - -# stmt = db.grunderfassung_unternehmen.insert().returning( -# db.grunderfassung_unternehmen.c.un_id, -# db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, -# db.grunderfassung_unternehmen.c.geloescht, -# ) -# ret = conn.execute(stmt, dump_data) - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excactly one returned row" -# data_from_db = from_db[0] -# auto_form_data.ids.un_id = data_from_db["un_id"] -# auto_form_data.geloescht = data_from_db["geloescht"] -# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ -# "Metadaten_aktualisierung" -# ] - -# logger.debug("[AutoForm -- backend] Inserted InitRec Company successfully") - -# else: -# logger.debug("[AutoForm -- backend] Update...") -# stmt = ( -# db.grunderfassung_unternehmen.update() -# .where(db.grunderfassung_unternehmen.c.un_id == auto_form_data.ids.un_id) -# .returning( -# db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, -# db.grunderfassung_unternehmen.c.geloescht, -# ) -# ) -# ret = conn.execute(stmt, dump_data) - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excatly one returned row" -# data_from_db = from_db[0] - -# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ -# "Metadaten_aktualisierung" -# ] - -# logger.debug( -# "[AutoForm -- backend] Updated InitRec Company with ID %d successfully", -# auto_form_data.ids.un_id, -# ) - -# return auto_form_data - - -# def initrec_company_from_db( -# id_: RecId, -# ) -> InitRec: -# logger.debug("[AutoForm -- backend] Call database loading routine...") -# stmt = db.grunderfassung_unternehmen.select().where( -# db.grunderfassung_unternehmen.c.un_id == id_ -# ) -# with db.ENGINE.connect() as conn: -# ret = conn.execute(stmt) - -# from_db = ret.mappings().all() -# assert len(from_db) == 1, "not excactly one company initial recording obtained" -# data_from_db = dict(from_db[0]) - -# geloescht = data_from_db["geloescht"] -# del data_from_db["geloescht"] - -# return InitRec( -# ids=EntityIds(un_id=id_), -# # rec_id=id_, # TODO remove -# geloescht=geloescht, -# db_data=data_from_db, -# ) - - -# def initrec_person_to_db( -# auto_form_data: InitRec, -# ) -> InitRec: -# logger.debug("[AutoForm -- backend] Call database saving routine...") - -# dump_data = copy.deepcopy(auto_form_data.db_data) -# dump_data["geloescht"] = auto_form_data.geloescht - -# with db.ENGINE.begin() as conn: -# if auto_form_data.ids.pers_id is None: -# logger.debug("[AutoForm -- backend] Insert...") - -# stmt = sa.insert(db.t_grunderfassung_personen).returning( -# db.t_grunderfassung_personen.c.pers_id, -# db.t_grunderfassung_personen.c.Metadaten_aktualisierung, -# db.t_grunderfassung_personen.c.geloescht, -# ) -# stmt_compiled = str(stmt.compile(db.ENGINE)) -# logger.debug( -# "[AutoForm -- backend] Data to insert:\nStatement: %s\n%s", -# stmt_compiled, -# pformat(dump_data), -# ) - -# ret = conn.execute(stmt, dump_data) - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excactly one returned row" -# data_from_db = from_db[0] -# auto_form_data.ids.pers_id = data_from_db["pers_id"] -# auto_form_data.geloescht = data_from_db["geloescht"] -# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ -# "Metadaten_aktualisierung" -# ] - -# logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") - -# else: -# logger.debug("[AutoForm -- backend] Update...") -# stmt = ( -# db.t_grunderfassung_personen.update() -# .where(db.t_grunderfassung_personen.c.pers_id == auto_form_data.ids.pers_id) -# .returning( -# db.t_grunderfassung_personen.c.Metadaten_aktualisierung, -# db.t_grunderfassung_personen.c.geloescht, -# ) -# ) -# ret = conn.execute(stmt, dump_data) - -# from_db = ret.mappings().fetchall() -# assert len(from_db) == 1, "expected excatly one returned row" -# data_from_db = from_db[0] - -# auto_form_data.db_data["Metadaten_aktualisierung"] = data_from_db[ -# "Metadaten_aktualisierung" -# ] - -# logger.debug( -# "[AutoForm -- backend] Updated InitRec Person with ID %d successfully", -# auto_form_data.ids.pers_id, -# ) - -# return auto_form_data - - -# def initrec_person_from_db( -# id_: RecId, -# ) -> InitRec: -# logger.debug("[AutoForm -- backend] Call database loading routine...") -# stmt = db.t_grunderfassung_personen.select().where( -# db.t_grunderfassung_personen.c.pers_id == id_ -# ) -# with db.ENGINE.connect() as conn: -# ret = conn.execute(stmt) - -# from_db = ret.mappings().all() -# assert len(from_db) == 1, "not excactly one company initial recording obtained" -# data_from_db = dict(from_db[0]) - -# geloescht = data_from_db["geloescht"] -# del data_from_db["geloescht"] - -# return InitRec( -# ids=EntityIds(pers_id=id_), -# # rec_id=id_, # TODO remove -# geloescht=geloescht, -# db_data=data_from_db, -# ) - - -# TODO check removal -# def initrec_company_get_initial_recording( -# id_: RecId, -# ) -> dict[str, Any]: -# logger.debug("[Call backend] get_initial_recording") -# stmt = db.grunderfassung_unternehmen.select().where( -# db.grunderfassung_unternehmen.c.un_id == id_ -# ) -# with db.ENGINE.connect() as conn: -# ret = conn.execute(stmt) - -# results = ret.mappings().all() -# if not results: -# raise KeyError(f"Database ID {id_} not found") - -# assert len(results) == 1, "more than one company initial recording obtained" - -# row = results[0] -# assert row, "row was not obtained" - -# return dict(row) - - def split_dict( dic: dict[K, V], split_keys: Container[K], @@ -612,85 +386,6 @@ def page_initrec_from_db( ) -# def initrec_company_delete_initial_recording( -# id_: RecId, -# ) -> None: -# logger.debug("[Call backend] delete_initial_recording") -# stmt = db.grunderfassung_unternehmen.delete().where( -# db.grunderfassung_unternehmen.c.un_id == id_ -# ) -# with db.ENGINE.begin() as conn: -# ret = conn.execute(stmt) - -# if ret.rowcount == 0: -# raise KeyError(f"Database ID {id_} not found for deletion") - - -# def initrec_person_insert_initial_recording( -# data: dict[str, Any], -# ) -> RecId: -# logger.debug("[Call backend] insert_initial_recording") -# stmt = db.grunderfassung_personen.insert() -# with db.ENGINE.begin() as conn: -# ret = conn.execute(stmt, data) - -# if ret.rowcount == 0: -# raise IOError("Entry was not inserted correctly") - -# prim_keys = ret.inserted_primary_key -# assert prim_keys - -# return prim_keys[0] - - -# def initrec_person_update_initial_recording( -# id_: RecId, -# data: dict[str, Any], -# ) -> None: -# logger.debug("[Call backend] update_initial_recording") -# stmt = db.grunderfassung_personen.update().where( -# db.grunderfassung_personen.c.pers_id == id_ -# ) -# with db.ENGINE.begin() as conn: -# conn.execute(stmt, data) - - -# def initrec_person_get_initial_recording( -# id_: RecId, -# ) -> dict[str, Any]: -# logger.debug("[Call backend] get_initial_recording person") -# stmt = db.grunderfassung_personen.select().where( -# db.grunderfassung_personen.c.pers_id == id_ -# ) -# with db.ENGINE.connect() as conn: -# ret = conn.execute(stmt) - -# results = ret.mappings().all() -# if not results: -# raise KeyError(f"Database ID {id_} not found") - -# assert len(results) == 1, "more than one person initial recording obtained" - -# row = results[0] -# assert row, "row was not obtained" - -# return dict(row) - - -# def initrec_person_delete_initial_recording( -# id_: RecId, -# ) -> None: -# logger.debug("[Call backend] delete_initial_recording") -# stmt = db.grunderfassung_personen.delete().where( -# db.grunderfassung_personen.c.pers_id == id_ -# ) -# with db.ENGINE.begin() as conn: -# ret = conn.execute(stmt) - -# if ret.rowcount == 0: -# raise KeyError(f"Database ID {id_} not found for deletion") - - @wrap_result(10) def page_consulting_to_db( consultation_data: Beratungsgespraech_Vorgang, @@ -1031,7 +726,7 @@ def companyprofile_page_get_consultations( # // initial recording interaction -# TODO wrap result +# TODO wrap result, add to corresponding component def initrec_link_person_company( un_id: RecId, pers_id: RecId, @@ -1063,89 +758,71 @@ def initrec_link_person_company( # // main page interaction -# TODO change to correct logic -def _main_page_get_company_list() -> list[MainPageEntry]: - logger.debug("[Call backend] get_company_list") - - raise RuntimeError("TODOs to implement") - - stmt = sa.select( - db.grunderfassung_unternehmen.c.un_id, - db.grunderfassung_unternehmen.c.Partnersuche__un_suche, - db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - ).where(db.grunderfassung_unternehmen.c.geloescht.is_(None)) - - with db.ENGINE.connect() as conn: - res = conn.execute(stmt) - - main_page_companies: list[MainPageEntry] = [] - - for entry in res.mappings(): - rec_id = entry["un_id"] - assert rec_id, "no RecID defined" - ma_id_external = entry["Partnersuche__un_suche"] - assert ma_id_external is not None, "external MA ID is NULL" - datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) - datetime_akt = datetime_akt.astimezone(TIMEZONE_CEST) - - comp_info = external_company_get_info(ma_id_external) - display_name = comp_info["ma_unternehmensname"] - - main_page_companies.append( - MainPageEntry( - rec_id=rec_id, - display_name=display_name, - Metadaten_aktualisierung=datetime_akt, - type=RecordingType.WITH_COMPANY, - ) - ) - - return main_page_companies - - -def _main_page_get_person_list() -> list[MainPageEntry]: - logger.debug("[Call backend] get_company_list") - stmt = sa.select( - db.t_grunderfassung_personen.c.pers_id, - db.t_grunderfassung_personen.c.Stammdaten__vorname, - db.t_grunderfassung_personen.c.Stammdaten__name, - db.t_grunderfassung_personen.c.Metadaten_aktualisierung, - ).where(db.t_grunderfassung_personen.c.geloescht.is_(None)) - - with db.ENGINE.connect() as conn: - res = conn.execute(stmt) - - main_page_persons: list[MainPageEntry] = [] - - for entry in res.mappings(): - rec_id = entry["pers_id"] - assert rec_id, "no RecID defined" - datetime_akt = cast(datetime.datetime, entry["Metadaten_aktualisierung"]) - datetime_akt = datetime_akt.astimezone() - - surname: str = entry["Stammdaten__name"] - first_name: str = entry["Stammdaten__vorname"] - names_to_join = [n for n in (first_name, surname) if n] - display_name = " ".join(names_to_join) - - main_page_persons.append( - MainPageEntry( - rec_id=rec_id, - display_name=display_name, - Metadaten_aktualisierung=datetime_akt, - type=RecordingType.WITHOUT_COMPANY, - ) - ) - - return main_page_persons - - def main_page_get_entries() -> list[MainPageEntry]: - companies = _main_page_get_company_list() - persons = _main_page_get_person_list() + with db.ENGINE.connect() as conn: + q_relevant_entries = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.aktualisiert, + db.t_zuordnung_personen_unternehmen.c.id.label("link_id"), + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .where(db.t_grunderfassung_personen.c.geloescht.is_(None)) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ).subquery("relevant_entries") - all_entries = companies + persons + stmt = sa.select( + q_relevant_entries, + db.t_ext_crm_master_attach.c.ma_unternehmensname, + ).outerjoin( + db.t_ext_crm_master_attach, + q_relevant_entries.c.un_id == db.t_ext_crm_master_attach.c.ma_id, + ) - all_entries.sort(key=lambda x: x.Metadaten_aktualisierung, reverse=True) + ret = conn.execute(stmt) - return all_entries + main_page_entries: list[MainPageEntry] = [] + for entry in ret.mappings(): + ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], + ) + recording_type = RecordingType.WITHOUT_COMPANY + if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + + first_name = entry["Stammdaten__vorname"] + last_name = entry["Stammdaten__name"] + location_city = entry["Stammdaten__ort"] + person_name = f"{first_name} {last_name}" + if location_city: + person_name += f" (Ort: {location_city})" + + company_name = entry["ma_unternehmensname"] + + display_name = person_name + if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + + main_page_entries.append( + MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, + ) + ) + main_page_entries.sort(key=lambda x: x.dt_updated, reverse=True) + + return main_page_entries diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 6ce0bf0..4a3f578 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -199,6 +199,14 @@ class Page_InitRec_Form_Data: company: dict[str, Any] +def _create_empty_initrec_form_data() -> Page_InitRec_Form_Data: + return Page_InitRec_Form_Data( + metadata={}, + person={}, + company={}, + ) + + # !! there must be exactly one InitRecForm for each recording type @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_State(PageState["InitRecForm"]): @@ -208,7 +216,9 @@ class Page_InitRec_State(PageState["InitRecForm"]): # un_id: RecId | None = None locked: bool geloescht: bool = False - form_data: Page_InitRec_Form_Data + form_data: Page_InitRec_Form_Data = dc.field( + default_factory=_create_empty_initrec_form_data + ) initrec_form_states: dict[EntityType, Page_InitRec_Form_State] = dc.field( init=False, default_factory=dict ) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 572571f..2cd2a18 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3510,15 +3510,14 @@ class Page_InitRec(QWidget): logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) self._reset() - ids = EntityIds( - # un_id=3, - # an_id=7, - pers_id=5, - ) - # ids = EntityIds( - # link_id=7, + # # un_id=3, + # # an_id=7, + # pers_id=5, # ) + # # ids = EntityIds( + # # link_id=7, + # # ) self.STATE.ids.update(ids, ignore_none=False) if ids.empty(): @@ -6239,7 +6238,7 @@ class MainWindow(QMainWindow): self.initrec.back_main_requested.connect(self.show_main_page) self.initrec.back_requested.connect(self.simple_go_back) self.initrec.company_profile_requested.connect(self.show_page_company_profile) - # self.newinitrec.update_triggered.connect(self.update_grid) + self.initrec.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec) # SITE: 'Unternehmensprofil' @@ -6344,70 +6343,53 @@ class MainWindow(QMainWindow): for col_idx, title in enumerate(headers): self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) - # self.update_grid() + self.update_grid() return main_widget def _clear_layout(self) -> None: clear_layout(self.grid) - # def update_grid(self) -> None: - # clear_layout(self.grid) + def update_grid(self) -> None: + clear_layout(self.grid) - # data = backend.main_page_get_entries() + data = backend.main_page_get_entries() - # for entry in data: - # self.add_row_to_grid(entry) + for entry in data: + self.add_row_to_grid(entry) # TODO rework - # def add_row_to_grid( - # self, - # entry: types.MainPageEntry, - # ): - # row = self.current_row + def add_row_to_grid( + self, + entry: types.MainPageEntry, + ): + row = self.current_row - # # NAME - # cell_name = MainPage_ClickableCell(entry.display_name, entry) - # cell_name.clicked.connect(self.goto_initial_recording) - # self.grid.addWidget(cell_name, row, 0) - # # DATE - # cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) - # cell_date = MainPage_ClickableCell(cell_date_name, entry) - # cell_date.clicked.connect(self.goto_initial_recording) - # self.grid.addWidget(cell_date, row, 4) + # NAME + cell_name = MainPage_ClickableCell(entry.display_name, entry) + cell_name.clicked.connect(self.goto_initial_recording) + self.grid.addWidget(cell_name, row, 0) + # DATE + cell_date_name = entry.dt_updated.date().strftime(DATE_FMT) + cell_date = MainPage_ClickableCell(cell_date_name, entry) + cell_date.clicked.connect(self.goto_initial_recording) + self.grid.addWidget(cell_date, row, 4) - # self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen + self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen # TODO rework - # def goto_initial_recording( - # self, - # data: types.MainPageEntry, - # ): - # target: QWidget - # if data.type is types.RecordingType.COMPANY: - # ids = EntityIds(un_id=data.rec_id, type=RecordingType.COMPANY) - # new_state = Page_InitRecCompany_State( - # session=self.STATE.session, - # ids=ids, - # # un_id=data.rec_id, # TODO remove - # locked=True, - # ) - # self.initrec_company.load_state(new_state) - # target = self.initrec_company - # elif data.type is types.RecordingType.PERSON: - # ids = EntityIds(pers_id=data.rec_id, type=RecordingType.PERSON) - # new_state = Page_InitRecPerson_State( - # session=self.STATE.session, - # ids=ids, - # # pers_id=data.rec_id, # TODO remove - # locked=True, - # ) - # self.initrec_person.load_state(new_state) - # target = self.initrec_person - # else: - # raise TypeError(f"Unknown init rec type: {data.type}") - - # self._navigate_to_target(target) + def goto_initial_recording( + self, + data: types.MainPageEntry, + ): + new_state = Page_InitRec_State( + session=self.STATE.session, + ids=data.ids, + recording_type=data.recording_type, + locked=True, + ) + self.initrec.load_state(new_state) + self._navigate_to_target(self.initrec) def _navigate_to_target( self, diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index 8fe1d55..c96e9ff 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -244,10 +244,10 @@ class ContactPersonInfo(TypedDict): # ** internals @dc.dataclass(slots=True) class MainPageEntry: - rec_id: RecId + ids: EntityIds display_name: str - Metadaten_aktualisierung: datetime.datetime - type: RecordingType + dt_updated: datetime.datetime + recording_type: RecordingType @dc.dataclass(slots=True) -- 2.34.1 From 07ae18271eabe6f191b40845a432a0fcf4470f25 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 30 Jul 2026 15:05:56 +0200 Subject: [PATCH 65/74] prepare document selection widget --- data_model/project_db_schema.aml | 25 +- prototypes/initrec_file_selection.py | 412 +++++++++++++++++++++++++ prototypes/tests.py | 2 +- src/wce_crm/constants.py | 2 +- src/wce_crm/data_models.py | 29 ++ src/wce_crm/db.py | 27 +- src/wce_crm/form_defs.py | 4 +- src/wce_crm/gui.py | 27 +- src/wce_crm/gui_components/__init__.py | 0 9 files changed, 501 insertions(+), 27 deletions(-) create mode 100644 prototypes/initrec_file_selection.py create mode 100644 src/wce_crm/gui_components/__init__.py diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 7b10d53..1f2a46d 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -1,15 +1,18 @@ Ext_Kontaktliste.Master ma_id int pk + Ext_Kontaktliste.Ansprechpartner an_id int pk ma_id int -> Ext_Kontaktliste.Master(ma_id) + Unternehmen | Es wird nur die ID für die Kontaktliste referenziert. Alle relevanten Infos werden live aus der Kontaktliste geladen via 'ATTACH'. un_id int pk -> Ext_Kontaktliste.Master(ma_id) erstellt "timestamp with time zone" geloescht "timestamp with time zone" nullable + Personen_Unternehmen_Zuordnung id int pk {autoIncrement} pers_id int pk -> Grunderfassung_Personen(pers_id) unique=uq_aktive_zuordnung @@ -20,6 +23,7 @@ Personen_Unternehmen_Zuordnung aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} geloescht "timestamp with time zone" nullable | Soft-Delete Marker (partieller Unique-Index: WHERE geloescht IS NULL) + Grunderfassung_Personen pers_id int pk {autoIncrement} erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` @@ -72,16 +76,19 @@ Grunderfassung_Personen WeitereInfos__WI_deutsch_sprache text nullable WeitereInfos__WI_gueltigkeit_aufenthaltstitel text nullable WeitereInfos__WI_meldung_institution text nullable -# Metadaten_erstellung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable -# Metadaten_aktualisierung "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` nullable {on_update: `datetime.datetime.now(datetime.UTC)`} -# Zuordnung_Personen_Unternehmen | Eine Person kann zu mehreren Unternehmen gehören, ein Unternehmen kann mehrere Personen betreuen. -# id int pk {autoIncrement} -# un_id int -> Grunderfassung_Unternehmen(un_id) -# pers_id int -> Grunderfassung_Personen(pers_id) -# gueltig_ab "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` -# gueltig_bis "timestamp with time zone" nullable +Grunderfassung_Dokumente + id int pk {autoIncrement} + pers_id int -> Grunderfassung_Personen(pers_id) + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} + geloescht "timestamp with time zone" nullable + speicher_pfad text + dateiname_original text + dateigroesse int nullable + mime_type text + anmerkung text Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein (pauschal) oder beide (individual) gebunden sein @@ -93,7 +100,7 @@ Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} geloescht "timestamp with time zone" nullable -# temporaer bool | Gibt an, ob der Vorgang losgelöst von der Grunderfassung gestartet wurde (Daten müssen nachträglich zugeordnet werden); theoretisch auch erfüllt, wenn `un_id` und `pers_id` beide NULL sind + Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden beratung_id int pk {autoIncrement} diff --git a/prototypes/initrec_file_selection.py b/prototypes/initrec_file_selection.py new file mode 100644 index 0000000..acbb1b6 --- /dev/null +++ b/prototypes/initrec_file_selection.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import datetime +import os +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtCore import ( + QFileInfo, + QMimeDatabase, + Qt, + QUrl, + Signal, +) +from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFileDialog, + QFileIconProvider, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from wce_crm.constants import TIMEZONE_CEST + +if TYPE_CHECKING: + from wce_crm.data_models import Module + + +def get_mime_type( + file_path: Path, +) -> str: + """gets the MIME type of a file via PySide6 / QMimeDatabase""" + db = QMimeDatabase() + mime_type = db.mimeTypeForFile(str(file_path)) + + # .name() -> for example "application/pdf" or "image/png" + return mime_type.name() + + +def _format_file_size(size_in_bytes): + """converts bytes in readable string, e.g. KB or MB""" + if size_in_bytes < 1024: + return f"{size_in_bytes} B" + elif size_in_bytes < 1024 * 1024: + return f"{size_in_bytes / 1024:.1f} KB" + else: + return f"{size_in_bytes / (1024 * 1024):.1f} MB" + + +class _DropZoneWidget(QFrame): + files_dropped = Signal(list) # sends list of pathlib.Path objects + + STYLE_NORMAL = """ + _DropZoneWidget { + border: 2px dashed #B0B0B0; + border-radius: 8px; + background-color: #F9F9F9; + } + _DropZoneWidget:hover { + border-color: #0078D4; + background-color: #F0F6FF; + } + """ + + STYLE_DRAG_ACTIVE = """ + _DropZoneWidget { + border: 2px dashed #0078D4; + border-radius: 8px; + background-color: #E1EFFA; + } + """ + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.setAcceptDrops(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self.setStyleSheet(self.STYLE_NORMAL) + self.setFixedHeight(60) + + layout = QVBoxLayout(self) + self.label = QLabel( + "📁 Dateien hierher ziehen oder klicken zum Auswählen" + ) + self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.label) + + def dragEnterEvent( + self, + event: QDragEnterEvent, + ): + if event.mimeData().hasUrls(): + # check if MDB/explorer gives at least one real file (no folder) + has_files = False + for url in event.mimeData().urls(): + pfad = url.toLocalFile() + if pfad and os.path.isfile(pfad): + has_files = True + break + + if has_files: + event.acceptProposedAction() + self.setStyleSheet(self.STYLE_DRAG_ACTIVE) + + def dragLeaveEvent(self, event): + self.setStyleSheet(self.STYLE_NORMAL) + + def dropEvent(self, event: QDropEvent): + self.dragLeaveEvent(None) + + valid_files: list[Path] = [] + for url in event.mimeData().urls(): + if url.isLocalFile(): + path = Path(url.toLocalFile()).resolve() + # only add if it is a file + if path.is_file(): + valid_files.append(path) + + if valid_files: + self.files_dropped.emit(valid_files) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + files, _ = QFileDialog.getOpenFileNames(self, "Dokumente auswählen") + if files: + files_pathlib = [Path(file).resolve() for file in files] + self.files_dropped.emit(files_pathlib) + + +class _FileItemWidget(QFrame): + request_remove = Signal(QWidget) # signal to parent to remove entry + + def __init__( + self, + file_path: Path, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.file_path = file_path + self._created_at_UTC = datetime.datetime.now(tz=datetime.UTC) + self.created_at_string = self._created_at_UTC.astimezone(TIMEZONE_CEST).strftime( + "%d.%m.%Y %H:%M" + ) + + self.setStyleSheet(""" + _FileItemWidget { + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 6px; + padding: 6px; + } + _FileItemWidget:hover { + border-color: #B0B0B0; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + + # file icon: use dummy to avoid icons with checkmarks (synchronised folder) + icon_label = QLabel() + provider = QFileIconProvider() + file_icon = provider.icon(QFileInfo(f"dummy{file_path.suffix}")) + icon_label.setPixmap(file_icon.pixmap(28, 28)) + layout.addWidget(icon_label) + + # file info: name, size, date, mime type + info_vbox = QVBoxLayout() + info_vbox.setSpacing(2) + + self.original_name = file_path.name + self.mime_type = get_mime_type(file_path) + self._raw_file_size: int | None = None + self.file_size_display: str + try: + self._raw_file_size = file_path.stat().st_size + self.file_size_display = _format_file_size(self._raw_file_size) + except OSError: + self.file_size_display = "Unbekannt" + + name_label = QLabel(f"{self.original_name}") + meta_label = QLabel( + ( + f"{self.file_size_display} • Hinzugefügt: " + f"{self.created_at_string}" + ) + ) + meta_label.setStyleSheet("font-size: 11px;") + + # additional note field for more comprehensible file descriptions + self.note_edit = QLineEdit() + self.note_edit.setPlaceholderText("Eigene Anmerkung / Bez. eingeben (optional)...") + self.note_edit.setStyleSheet(""" + QLineEdit { + font-size: 11px; + padding: 2px 5px; + min-height: 22px; /* avoid vertical cropping of text */ + border: 1px solid #CCCCCC; + border-radius: 3px; + } + QLineEdit:focus { + border-color: #0078D4; /* blue frame on click */ + } + """) + + info_vbox.addWidget(name_label) + info_vbox.addWidget(meta_label) + info_vbox.addWidget(self.note_edit) + layout.addLayout(info_vbox, stretch=2) + + # category dropdown: not needed by WCE, but still included to re-enable later + # TODO maybe add later + self.category_combo = QComboBox() + self.category_combo.addItems( + [ + "Allgemein / Unsortiert", + "Identitätsnachweis", + "Vertrag", + "Gewerbeanmeldung", + "Sonstiges", + ] + ) + self.category_combo.setFixedWidth(160) + layout.addWidget(self.category_combo) + self.category_combo.setVisible(False) + + # action buttons: view and delete + self.btn_preview = QPushButton("👁️") + self.btn_preview.setToolTip("Datei im Standardprogramm öffnen") + self.btn_preview.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_preview.setFixedWidth(36) + self.btn_preview.clicked.connect(self._open_preview) + + self.btn_delete = QPushButton("🗑️") + self.btn_delete.setToolTip("Datei entfernen") + self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_delete.setFixedWidth(36) + self.btn_delete.clicked.connect(lambda: self.request_remove.emit(self)) + + layout.addWidget(self.btn_preview) + layout.addWidget(self.btn_delete) + + def _open_preview(self): + QDesktopServices.openUrl(QUrl.fromLocalFile(self.file_path)) + + # TODO change to protocol/interface with state + def get_data(self): + return { + "file_path": self.file_path, + "created_at": self._created_at_UTC, + "category": None, + "note": self.note_edit.text().strip(), + } + + +class DocumentSectionWidget(QWidget): + """main widget for file selection""" + + count_changed = Signal(int) # sends current number of files contained by the widget + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + # Dropzone at the top + self.drop_zone = _DropZoneWidget() + self.drop_zone.files_dropped.connect(self._add_files) + main_layout.addWidget(self.drop_zone) + + # list containing the file items + self.file_list_widget = QListWidget() + self.file_list_widget.setStyleSheet(""" + QListWidget { + border: none; + background: transparent; + padding: 0px; + margin: 0px; + } + QListWidget::item { + padding: 0px; + margin: 0px 0px 6px 0px; /* 6px Abstand zwischen den einzelnen Datei-Zeilen */ + } + """) + + main_layout.addWidget(self.file_list_widget) + + def _add_files( + self, + filepaths: Iterable[Path], + ): + for path in filepaths: + # create custom row widget + item_widget = _FileItemWidget(path) + item_widget.request_remove.connect(self._remove_file_item) + + # embed in QListWidget + list_item = QListWidgetItem(self.file_list_widget) + # min size to avoid cropping of text + size_hint = item_widget.sizeHint() + size_hint.setHeight(size_hint.height() + 4) + list_item.setSizeHint(size_hint) + + self.file_list_widget.addItem(list_item) + self.file_list_widget.setItemWidget(list_item, item_widget) + + self.count_changed.emit(self.file_list_widget.count()) + + def _remove_file_item( + self, + widget: QWidget, + ): + # search and delete row + for i in range(self.file_list_widget.count()): + item = self.file_list_widget.item(i) + if self.file_list_widget.itemWidget(item) == widget: + self.file_list_widget.takeItem(i) + break + self.count_changed.emit(self.file_list_widget.count()) + + # TODO change to protocol/interface with state + def get_all_documents_data(self): + """Methode zum Auslesen aller angehängten Dateien inkl. Metadaten für das Speichern.""" + documents = [] + for i in range(self.file_list_widget.count()): + item = self.file_list_widget.item(i) + widget = self.file_list_widget.itemWidget(item) + if isinstance(widget, _FileItemWidget): + documents.append(widget.get_data()) + return documents + + +class _MainWindow(QMainWindow): + """main window for internal test only, not the this module's component""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Prototyp: Dokumente & Anhänge") + self.resize(650, 500) + + central_widget = QWidget() + layout = QVBoxLayout(central_widget) + + # simulate custom widget in form + self.header_label = QLabel("📎 Dokumente & Anhänge (0)") + self.header_label.setStyleSheet("font-size: 14px; color: #333333;") + layout.addWidget(self.header_label) + + # custom widget + self.doc_section = DocumentSectionWidget() + self.doc_section.count_changed.connect(self.update_header) + layout.addWidget(self.doc_section) + + # test button to trigger data gathering (simulation for saving procedure and + # backend interaction) + self.btn_save = QPushButton("Erfassung speichern (Daten auslesen)") + self.btn_save.setStyleSheet( + "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" + ) + self.btn_save.clicked.connect(self.print_backend_data) + layout.addWidget(self.btn_save) + + self.setCentralWidget(central_widget) + + def update_header(self, count): + """update counter in corresponding label, must be mapped to section heading in form""" + self.header_label.setText(f"📎 Dokumente & Anhänge ({count})") + + def print_backend_data(self): + data = self.doc_section.get_all_documents_data() + print("\n--- DATEN FÜR SPEICHERVORGANG BEREIT ---") + for i, doc in enumerate(data, 1): + print(f"Datei {i}:") + print(f" Pfad: {doc['file_path']}") + print(f" Datum: {doc['created_at']}") + print(f" Kategorie: {doc['category']}") + print(f" Anmerkung: {doc['note']}") + + +def main() -> None: + app = QApplication(sys.argv) + window = _MainWindow() + window.show() + sys.exit(app.exec()) + + +if TYPE_CHECKING: + # enable static type checking for the protocol + _m1: type[Module] = DocumentSectionWidget + _m2: type[Module] = _FileItemWidget + +if __name__ == "__main__": + main() diff --git a/prototypes/tests.py b/prototypes/tests.py index 46d3b72..c7f38d5 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -275,7 +275,7 @@ print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") # %% db_path = constants.Config.DB_PATH_MAIN -crm_path = constants.Config.DB_PATH_CRM +crm_path = constants.Config.DB_PATH_KONTAKTLISTE engine = sql.create_engine(f"sqlite:///{str(db_path)}") engine_crm = sql.create_engine(f"sqlite:///{str(crm_path)}") # %% diff --git a/src/wce_crm/constants.py b/src/wce_crm/constants.py index 8c9f695..f4f389d 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -19,7 +19,7 @@ assert BASE_PATH class Config: DEVELOPMENT_STATE: bool = bool(os.getenv("DOPT_DEVELOPMENT", None)) - DB_PATH_CRM: Path = BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db") + DB_PATH_KONTAKTLISTE: Path = BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db") DB_PATH_MAIN: Path = BASE_PATH / os.getenv( "DOPT_DB_MAIN", "data/db/wce_grunderfassung.db" ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 4a3f578..38b1989 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -5,6 +5,7 @@ import dataclasses as dc import datetime import json from collections.abc import Iterable, Sequence +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar from dopt_basics.datastructures import DualDict @@ -265,6 +266,34 @@ class Page_InitRec_Form_State(PageState[Module]): geloescht: bool = False +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_DocumentSection_State: + session: Session + pers_id: int | None + docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list) + locked: bool + geloescht: bool = False + + @property + def doc_count(self) -> int: + return len(self.docs) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_DocumentSection_Doc_State: + id: int | None + pers_id: int | None + datum_erstellt_utc: datetime.datetime = dc.field( + default_factory=lambda: datetime.datetime.now(datetime.UTC) + ) + + speicher_pfad: Path + dateiname_original: str + dateigroesse: int | None + locked: bool + geloescht: bool = False + + @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 2b4b231..acc99b6 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -45,29 +45,40 @@ class UTCDateTime(TypeDecorator): if value is not None: # Ensure it's converted to UTC before saving if value.tzinfo is None: - value = value.replace(tzinfo=datetime.timezone.utc) + value = value.replace(tzinfo=datetime.UTC) else: - value = value.astimezone(datetime.timezone.utc) + value = value.astimezone(datetime.UTC) return value def process_result_value(self, value, dialect): """Runs when fetching from the database.""" if value is not None and value.tzinfo is None: # Explicitly tell Python this data *is* UTC - value = value.replace(tzinfo=datetime.timezone.utc) + value = value.replace(tzinfo=datetime.UTC) return value MD_CRM = sa.MetaData() MD_MAIN = sa.MetaData() -ENGINE = sa.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") +URL_MAIN = sa.engine.URL.create( + "sqlite+pysqlite", + database=str(constants.Config.DB_PATH_MAIN), +) +ENGINE = sa.create_engine( + URL_MAIN, + connect_args={"uri": True, "timeout": 0.5}, +) + +KONTAKTLISTE_URI = constants.Config.DB_PATH_KONTAKTLISTE.as_uri() + "?mode=ro" @event.listens_for(ENGINE, "connect") -def attach_second_db(dbapi_connection, connection_record): +def _db_conn_event(dbapi_connection, connection_record): cursor = dbapi_connection.cursor() # 'kontaktliste' is the alias for the second DB - cursor.execute(f"ATTACH DATABASE '{constants.Config.DB_PATH_CRM}' AS kontaktliste") + cursor.execute("ATTACH DATABASE ? AS kontaktliste", (KONTAKTLISTE_URI,)) + # enable foreign keys + cursor.execute("PRAGMA foreign_keys=ON") cursor.close() @@ -146,7 +157,7 @@ def get_ext_crm_master( return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema) -DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_CRM) +DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_KONTAKTLISTE) ext_crm_nutzer: Table = Table( "Nutzer", @@ -253,7 +264,7 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) -DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) +DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_KONTAKTLISTE) t_ext_crm_master_attach: Table = Table( "Master", diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index c0da687..99bd975 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -132,7 +132,7 @@ class CountryList: def get_country_list_german() -> CountryList: - locale = babel.Locale("de", "DE") + locale = babel.Locale("en", "GB") countries: list[tuple[str, str]] = [] iso_to_country: dict[str, str] = {} @@ -938,7 +938,7 @@ INITREC_PERSON = [ trigger_value="ja", children=[ FormField( - "Relevanz", + "Relevant perspektivERZ", FormFieldType.DROPDOWN, required=True, options=[("ja", None), ("nein", None)], diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 2cd2a18..b1a1c6d 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3256,7 +3256,7 @@ class Page_InitRec(QWidget): self.modify_btn.modify_activated.connect(self.unlock) self.modify_btn.modify_deactivated.connect(self.lock) self.save_btn.clicked.connect(self.save_data) - self.reset_btn.clicked.connect(self._reset) + self.reset_btn.clicked.connect(self._trigger_reset_btn) self.delete_btn.clicked.connect(self._delete_data) header_layout.addSpacing(5) header_layout.addWidget(self.edit_buttons) @@ -3477,6 +3477,21 @@ class Page_InitRec(QWidget): ) return all(valid_id_states) + def _trigger_reset_btn(self) -> None: + assert not self.STATE.locked, "reset initialised, even though form is locked" + confirm = get_user_confirmation( + self, + "Zurücksetzen bestätigen", + ( + "Soll der Eintrag wirklich zurückgesetzt werden? Alle nicht " + "gespeicherten Änderungen gehen verloren." + ), + ) + if not confirm: + return + + self._sync_state_to_GUI(None) + def _delete_data(self) -> None: assert self._deletion_valid(), "deletion initialised despite IDs are not properly set" confirm = get_user_confirmation( @@ -3525,7 +3540,6 @@ class Page_InitRec(QWidget): return # TODO add loading logic with backend call - # !! just a dummy initrec = backend.page_initrec_from_db(ids) # TODO re-enable later @@ -4589,6 +4603,7 @@ class Page_Consulting(QWidget): container_layout.addWidget(self.info_banners_container) # --- BUTTONS --- + # TODO check if no reset button is correct self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=False) modify_btn = self.edit_buttons.modify_button assert modify_btn @@ -6165,7 +6180,7 @@ class EditButtons(QWidget): self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout_btn.addWidget(self.save_btn) # reset - self.reset_button: QPushButton | None = None + self.reset_btn: QPushButton | None = None if add_reset_btn: self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") self.reset_btn.setShortcut("Ctrl+Z") @@ -6185,12 +6200,15 @@ class EditButtons(QWidget): self, enable_modification: bool, set_delete_btn: bool = True, + set_reset_btn: bool = True, ) -> None: if self.modify_button is not None: self.modify_button.set_modify_state(enable_modification) self.save_btn.setEnabled(enable_modification) if set_delete_btn: self.delete_btn.setEnabled(enable_modification) + if self.reset_btn is not None and set_reset_btn: + self.reset_btn.setEnabled(enable_modification) class MainWindow(QMainWindow): @@ -6220,8 +6238,6 @@ class MainWindow(QMainWindow): new_initrec_select_state = Page_NewEntry_State(session=self.STATE.session) self.new_entry_select = Page_NewEntry(new_initrec_select_state) self.new_entry_select.back_requested.connect(self.show_main_page) - # self.new_initrec_select.company_requested.connect(self.show_page_initrec_company) - # self.new_initrec_select.person_requested.connect(self.show_page_initrec_person) self.new_entry_select.new_consulting_requested.connect(self.show_page_consulting) self.new_entry_select.new_initrec_requested.connect(self.show_page_initrec) self.stack.addWidget(self.new_entry_select) @@ -6504,7 +6520,6 @@ def qt_message_handler(mode, context, message): if TYPE_CHECKING: - # TODO add all relevant modules # enable static type checking for the protocol _m1: type[Module] = Page_Consulting _m2: type[Module] = Page_Consulting_Table diff --git a/src/wce_crm/gui_components/__init__.py b/src/wce_crm/gui_components/__init__.py new file mode 100644 index 0000000..e69de29 -- 2.34.1 From 2b6268e38a7dfd83e920f31fdb5416a9cf9e0fce Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 30 Jul 2026 16:41:50 +0200 Subject: [PATCH 66/74] begin adding state management to new document selection --- data_model/project_db_schema.aml | 2 +- src/wce_crm/data_models.py | 20 +- src/wce_crm/gui.py | 3 +- .../gui_components}/initrec_file_selection.py | 326 ++++++++++++++---- 4 files changed, 277 insertions(+), 74 deletions(-) rename {prototypes => src/wce_crm/gui_components}/initrec_file_selection.py (54%) diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index 1f2a46d..dac665a 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -86,7 +86,7 @@ Grunderfassung_Dokumente geloescht "timestamp with time zone" nullable speicher_pfad text dateiname_original text - dateigroesse int nullable + dateigroesse_bytes int nullable mime_type text anmerkung text diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 38b1989..cadb37e 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -25,6 +25,7 @@ from wce_crm.types import ConsultingType, EntityIds, EntityType, RecordingType if TYPE_CHECKING: from wce_crm.gui import InitRecForm, Page_Consulting_ConsultingSession # noqa: F401 + from wce_crm.gui_components.initrec_file_selection import FileItemWidget from wce_crm.types import ConsId, EntityType, RecId, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] @@ -267,29 +268,28 @@ class Page_InitRec_Form_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) -class Page_InitRec_DocumentSection_State: +class Page_InitRec_DocumentSection_State(PageState["FileItemWidget"]): session: Session pers_id: int | None + doc_count: int = 0 docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list) locked: bool geloescht: bool = False - @property - def doc_count(self) -> int: - return len(self.docs) - @dc.dataclass(slots=True, kw_only=True) -class Page_InitRec_DocumentSection_Doc_State: +class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): + session: Session id: int | None pers_id: int | None datum_erstellt_utc: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) ) - - speicher_pfad: Path - dateiname_original: str - dateigroesse: int | None + speicher_pfad: Path | None + dateiname_original: str = "" + dateigroesse_bytes: int | None = None + mime_type: str = "" + anmerkung: str = "" locked: bool geloescht: bool = False diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index b1a1c6d..1baa0b8 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -130,7 +130,6 @@ if TYPE_CHECKING: ) from wce_crm.types import ( ConsId, - ExtMaId, LinkingConsultationsEntryCompany, LinkingConsultationsEntryPerson, ) @@ -5626,7 +5625,7 @@ class Page_Consulting_ConsultingSession(QWidget): self.btn_delete.setEnabled(False) else: unlock_form(self.widget_registry) - self.btn_delete.setEnabled(False) + self.btn_delete.setEnabled(True) def _sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() diff --git a/prototypes/initrec_file_selection.py b/src/wce_crm/gui_components/initrec_file_selection.py similarity index 54% rename from prototypes/initrec_file_selection.py rename to src/wce_crm/gui_components/initrec_file_selection.py index acbb1b6..4f80cde 100644 --- a/prototypes/initrec_file_selection.py +++ b/src/wce_crm/gui_components/initrec_file_selection.py @@ -5,7 +5,8 @@ import os import sys from collections.abc import Iterable from pathlib import Path -from typing import TYPE_CHECKING +from pprint import pformat +from typing import TYPE_CHECKING, Literal from PySide6.QtCore import ( QFileInfo, @@ -32,11 +33,23 @@ from PySide6.QtWidgets import ( QWidget, ) +import wce_crm.constants from wce_crm.constants import TIMEZONE_CEST +from wce_crm.data_models import ( + Page_InitRec_DocumentSection_Doc_State, + Page_InitRec_DocumentSection_State, + Session, + set_page_state, +) +from wce_crm.logging import logger_gui as logger if TYPE_CHECKING: from wce_crm.data_models import Module +DEBUG: bool = True +if not wce_crm.constants.Config.DEVELOPMENT_STATE: + DEBUG = False + def get_mime_type( file_path: Path, @@ -49,7 +62,9 @@ def get_mime_type( return mime_type.name() -def _format_file_size(size_in_bytes): +def _format_file_size( + size_in_bytes: int, +) -> str: """converts bytes in readable string, e.g. KB or MB""" if size_in_bytes < 1024: return f"{size_in_bytes} B" @@ -142,20 +157,16 @@ class _DropZoneWidget(QFrame): self.files_dropped.emit(files_pathlib) -class _FileItemWidget(QFrame): +class FileItemWidget(QFrame): request_remove = Signal(QWidget) # signal to parent to remove entry def __init__( self, - file_path: Path, + state: Page_InitRec_DocumentSection_Doc_State, parent: QWidget | None = None, ): super().__init__(parent) - self.file_path = file_path - self._created_at_UTC = datetime.datetime.now(tz=datetime.UTC) - self.created_at_string = self._created_at_UTC.astimezone(TIMEZONE_CEST).strftime( - "%d.%m.%Y %H:%M" - ) + self.STATE = state self.setStyleSheet(""" _FileItemWidget { @@ -173,34 +184,19 @@ class _FileItemWidget(QFrame): layout.setContentsMargins(8, 6, 8, 6) # file icon: use dummy to avoid icons with checkmarks (synchronised folder) - icon_label = QLabel() - provider = QFileIconProvider() - file_icon = provider.icon(QFileInfo(f"dummy{file_path.suffix}")) - icon_label.setPixmap(file_icon.pixmap(28, 28)) - layout.addWidget(icon_label) + self.icon_label = QLabel() + self.icon_provider = QFileIconProvider() + layout.addWidget(self.icon_label) # file info: name, size, date, mime type info_vbox = QVBoxLayout() info_vbox.setSpacing(2) - self.original_name = file_path.name - self.mime_type = get_mime_type(file_path) - self._raw_file_size: int | None = None - self.file_size_display: str - try: - self._raw_file_size = file_path.stat().st_size - self.file_size_display = _format_file_size(self._raw_file_size) - except OSError: - self.file_size_display = "Unbekannt" + self.file_size_display: str = "" - name_label = QLabel(f"{self.original_name}") - meta_label = QLabel( - ( - f"{self.file_size_display} • Hinzugefügt: " - f"{self.created_at_string}" - ) - ) - meta_label.setStyleSheet("font-size: 11px;") + self.name_label = QLabel() + self.meta_label = QLabel() + self.meta_label.setStyleSheet("font-size: 11px;") # additional note field for more comprehensible file descriptions self.note_edit = QLineEdit() @@ -218,24 +214,24 @@ class _FileItemWidget(QFrame): } """) - info_vbox.addWidget(name_label) - info_vbox.addWidget(meta_label) + info_vbox.addWidget(self.name_label) + info_vbox.addWidget(self.meta_label) info_vbox.addWidget(self.note_edit) layout.addLayout(info_vbox, stretch=2) # category dropdown: not needed by WCE, but still included to re-enable later # TODO maybe add later self.category_combo = QComboBox() - self.category_combo.addItems( - [ - "Allgemein / Unsortiert", - "Identitätsnachweis", - "Vertrag", - "Gewerbeanmeldung", - "Sonstiges", - ] - ) - self.category_combo.setFixedWidth(160) + # self.category_combo.addItems( + # [ + # "Allgemein / Unsortiert", + # "Identitätsnachweis", + # "Vertrag", + # "Gewerbeanmeldung", + # "Sonstiges", + # ] + # ) + # self.category_combo.setFixedWidth(160) layout.addWidget(self.category_combo) self.category_combo.setVisible(False) @@ -255,17 +251,91 @@ class _FileItemWidget(QFrame): layout.addWidget(self.btn_preview) layout.addWidget(self.btn_delete) - def _open_preview(self): - QDesktopServices.openUrl(QUrl.fromLocalFile(self.file_path)) + if DEBUG: + btn_state = QPushButton("STATE") + btn_state.setToolTip("Debug call...") + btn_state.setCursor(Qt.CursorShape.PointingHandCursor) + btn_state.clicked.connect(self.get_state) + layout.addWidget(btn_state) - # TODO change to protocol/interface with state - def get_data(self): - return { - "file_path": self.file_path, - "created_at": self._created_at_UTC, - "category": None, - "note": self.note_edit.text().strip(), - } + self._sync_state_to_GUI(None) + + def _sync_state_to_GUI( + self, + section: Literal["locking", "content"] | None, + ) -> None: + + if section is None or section == "content": + created_at_string = self.STATE.datum_erstellt_utc.astimezone( + TIMEZONE_CEST + ).strftime("%d.%m.%Y %H:%M") + + file_icon = self.icon_provider.icon( + QFileInfo(f"dummy{self.STATE.speicher_pfad.suffix}") + ) + self.icon_label.setPixmap(file_icon.pixmap(28, 28)) + + self.STATE.dateiname_original = self.STATE.speicher_pfad.name + self.STATE.mime_type = get_mime_type(self.STATE.speicher_pfad) + + try: + self.STATE.dateigroesse_bytes = self.STATE.speicher_pfad.stat().st_size + self.file_size_display = _format_file_size(self.STATE.dateigroesse_bytes) + except OSError: + self.file_size_display = "Unbekannt" + + self.name_label.setText(f"{self.STATE.dateiname_original}") + self.meta_label.setText( + ( + f"{self.file_size_display} • Hinzugefügt: " + f"{created_at_string}" + ) + ) + self.note_edit.setText(self.STATE.anmerkung) + + if section is None or section == "locking": + if self.STATE.locked: + self.btn_delete.setEnabled(False) + self.note_edit.setReadOnly(True) + else: + self.btn_delete.setEnabled(True) + self.note_edit.setReadOnly(False) + + def _sync_GUI_to_state(self) -> None: + self.STATE.anmerkung = self.note_edit.text().strip() + + def _open_preview(self): + QDesktopServices.openUrl(QUrl.fromLocalFile(self.STATE.speicher_pfad)) + + def validate(self) -> list[str]: + err_list: list[str] = [] + return err_list + + def save_data(self) -> None: + pass + + def load_state( + self, + new_state: Page_InitRec_DocumentSection_Doc_State, + ) -> None: + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI(None) + + def lock(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") + + def get_state(self) -> Page_InitRec_DocumentSection_Doc_State: + self._sync_GUI_to_state() + logger.debug( + "[Module -- DocumentSelection] Current state of row:\n%s", + pformat(self.STATE), + ) + return self.STATE class DocumentSectionWidget(QWidget): @@ -275,9 +345,11 @@ class DocumentSectionWidget(QWidget): def __init__( self, + state: Page_InitRec_DocumentSection_State, parent: QWidget | None = None, ): super().__init__(parent) + self.STATE = state main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -304,14 +376,127 @@ class DocumentSectionWidget(QWidget): main_layout.addWidget(self.file_list_widget) + def _sync_state_to_GUI(self, section: Literal["content", "locking"] | None) -> None: + self.setUpdatesEnabled(False) + + if section is None or section == "content": + self.remove_all_rows() + for doc in self.STATE.docs: + if not doc.geloescht: + self.add_row(doc, True) + + self.count_changed.emit(self.STATE.doc_count) + + if section is None or section == "locking": + if self.STATE.locked: + for m in self.STATE.child_modules: + m.lock() + else: + for m in self.STATE.child_modules: + m.unlock() + + self.setUpdatesEnabled(True) + + def _sync_GUI_to_state(self) -> None: + logger.debug( + ( + "[Module -- DocumentSelection] Document selection widget: Call " + "syncing GUI to state" + ) + ) + logger.debug( + "[Module -- DocumentSelection] Document selection widget - Child modules: %s", + pformat(self.STATE.child_modules), + ) + + # Child modules are ACTIVE elements, viewable and changeable by the user. + # The states are already part of this widget and linked by reference, but + # the current user input must be synced with these states. Therefore, the + # `get_state` method of these children is called, which implicitly updates + # their state. The returned state reference is not needed. + for m in self.STATE.child_modules: + _ = m.get_state() + + self.STATE.doc_count = self.file_list_widget.count() + + def lock(self) -> None: + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + self.STATE.locked = False + self._sync_state_to_GUI("locking") + + def validate(self) -> list[str]: + err_list: list[str] = [] + for m in self.STATE.child_modules: + err_list.extend(m.validate()) + + return err_list + + def load_state( + self, + new_state: Page_InitRec_DocumentSection_State, + ) -> None: + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI(None) + + def save_data(self) -> None: + pass + + def remove_all_rows(self) -> None: + while self.STATE.child_modules: + self._remove_row(self.STATE.child_modules[0]) + + def add_row( + self, + row_state: Page_InitRec_DocumentSection_Doc_State | None, + state_exists: bool, + ) -> None: + if row_state is None: + new_state = Page_InitRec_DocumentSection_Doc_State( + session=self.STATE.session, + id=None, + pers_id=self.STATE.pers_id, + speicher_pfad=None, + locked=self.STATE.locked, + ) + + item_widget = FileItemWidget(new_state) + item_widget.request_remove.connect(self._remove_row) + # embed in QListWidget + list_item = QListWidgetItem(self.file_list_widget) + # min size to avoid cropping of text + size_hint = item_widget.sizeHint() + size_hint.setHeight(size_hint.height() + 4) + list_item.setSizeHint(size_hint) + + self.file_list_widget.addItem(list_item) + self.file_list_widget.setItemWidget(list_item, item_widget) + + self.STATE.child_modules.append(item_widget) + if not state_exists: + self.STATE.docs.append(new_state) + + self.STATE.doc_count = self.file_list_widget.count() + def _add_files( self, filepaths: Iterable[Path], ): for path in filepaths: # create custom row widget - item_widget = _FileItemWidget(path) - item_widget.request_remove.connect(self._remove_file_item) + new_state = Page_InitRec_DocumentSection_Doc_State( + session=self.STATE.session, + id=None, + pers_id=self.STATE.pers_id, + speicher_pfad=path, + locked=self.STATE.locked, + ) + self.STATE.docs.append(new_state) + item_widget = FileItemWidget(new_state) + item_widget.request_remove.connect(self._remove_row) + self.STATE.child_modules.append(item_widget) # embed in QListWidget list_item = QListWidgetItem(self.file_list_widget) @@ -323,9 +508,10 @@ class DocumentSectionWidget(QWidget): self.file_list_widget.addItem(list_item) self.file_list_widget.setItemWidget(list_item, item_widget) + self.STATE.doc_count = self.file_list_widget.count() self.count_changed.emit(self.file_list_widget.count()) - def _remove_file_item( + def _remove_row( self, widget: QWidget, ): @@ -333,9 +519,23 @@ class DocumentSectionWidget(QWidget): for i in range(self.file_list_widget.count()): item = self.file_list_widget.item(i) if self.file_list_widget.itemWidget(item) == widget: + assert isinstance(widget, FileItemWidget) self.file_list_widget.takeItem(i) + self.STATE.child_modules.remove(widget) break - self.count_changed.emit(self.file_list_widget.count()) + # do not remove the corresponding state since it is tracked + # to be post-processed when the database saving operation is triggered + self.STATE. + self.count_changed.emit()) + + def get_state(self) -> Page_InitRec_DocumentSection_State: + self._sync_GUI_to_state() + logger.debug( + "[Module -- DocumentSelection] Current state of document selection widget:\n%s", + pformat(self.STATE), + ) + + return self.STATE # TODO change to protocol/interface with state def get_all_documents_data(self): @@ -344,8 +544,8 @@ class DocumentSectionWidget(QWidget): for i in range(self.file_list_widget.count()): item = self.file_list_widget.item(i) widget = self.file_list_widget.itemWidget(item) - if isinstance(widget, _FileItemWidget): - documents.append(widget.get_data()) + if isinstance(widget, FileItemWidget): + documents.append(widget.get_state()) return documents @@ -366,7 +566,11 @@ class _MainWindow(QMainWindow): layout.addWidget(self.header_label) # custom widget - self.doc_section = DocumentSectionWidget() + self.SESSION = Session(user_id=42, user_name="TEST-USER") + state = Page_InitRec_DocumentSection_State( + session=self.SESSION, pers_id=None, locked=False + ) + self.doc_section = DocumentSectionWidget(state) self.doc_section.count_changed.connect(self.update_header) layout.addWidget(self.doc_section) @@ -406,7 +610,7 @@ def main() -> None: if TYPE_CHECKING: # enable static type checking for the protocol _m1: type[Module] = DocumentSectionWidget - _m2: type[Module] = _FileItemWidget + _m2: type[Module] = FileItemWidget if __name__ == "__main__": main() -- 2.34.1 From 1c586299fca08b8162b27f5f8a80cde5a0aef3a5 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 30 Jul 2026 16:45:59 +0200 Subject: [PATCH 67/74] prepare correct row initialisation --- src/wce_crm/data_models.py | 2 +- .../gui_components/initrec_file_selection.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index cadb37e..50a5a85 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -285,7 +285,7 @@ class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): datum_erstellt_utc: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) ) - speicher_pfad: Path | None + speicher_pfad: Path dateiname_original: str = "" dateigroesse_bytes: int | None = None mime_type: str = "" diff --git a/src/wce_crm/gui_components/initrec_file_selection.py b/src/wce_crm/gui_components/initrec_file_selection.py index 4f80cde..04c5867 100644 --- a/src/wce_crm/gui_components/initrec_file_selection.py +++ b/src/wce_crm/gui_components/initrec_file_selection.py @@ -384,7 +384,7 @@ class DocumentSectionWidget(QWidget): for doc in self.STATE.docs: if not doc.geloescht: self.add_row(doc, True) - + self.count_changed.emit(self.STATE.doc_count) if section is None or section == "locking": @@ -416,7 +416,7 @@ class DocumentSectionWidget(QWidget): # their state. The returned state reference is not needed. for m in self.STATE.child_modules: _ = m.get_state() - + self.STATE.doc_count = self.file_list_widget.count() def lock(self) -> None: @@ -448,6 +448,8 @@ class DocumentSectionWidget(QWidget): while self.STATE.child_modules: self._remove_row(self.STATE.child_modules[0]) + # !! the row is never None, there are no 'empty' rows + # TODO def add_row( self, row_state: Page_InitRec_DocumentSection_Doc_State | None, @@ -477,7 +479,7 @@ class DocumentSectionWidget(QWidget): self.STATE.child_modules.append(item_widget) if not state_exists: self.STATE.docs.append(new_state) - + self.STATE.doc_count = self.file_list_widget.count() def _add_files( @@ -509,7 +511,7 @@ class DocumentSectionWidget(QWidget): self.file_list_widget.setItemWidget(list_item, item_widget) self.STATE.doc_count = self.file_list_widget.count() - self.count_changed.emit(self.file_list_widget.count()) + self.count_changed.emit(self.STATE.doc_count) def _remove_row( self, @@ -525,8 +527,8 @@ class DocumentSectionWidget(QWidget): break # do not remove the corresponding state since it is tracked # to be post-processed when the database saving operation is triggered - self.STATE. - self.count_changed.emit()) + self.STATE.doc_count = self.file_list_widget.count() + self.count_changed.emit(self.STATE.doc_count) def get_state(self) -> Page_InitRec_DocumentSection_State: self._sync_GUI_to_state() -- 2.34.1 From ecb9a9cf1329652df360eb870bf96964732384c1 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 09:35:49 +0200 Subject: [PATCH 68/74] document selection: add Pydantic models and tests for saving/loading --- data_model/project_db_schema.aml | 1 + src/wce_crm/data_models.py | 29 +- src/wce_crm/gui.py | 97 +------ src/wce_crm/gui_components/dialogs.py | 78 ++++++ .../gui_components/initrec_file_selection.py | 262 +++++++++++++----- 5 files changed, 314 insertions(+), 153 deletions(-) create mode 100644 src/wce_crm/gui_components/dialogs.py diff --git a/data_model/project_db_schema.aml b/data_model/project_db_schema.aml index dac665a..df381e6 100644 --- a/data_model/project_db_schema.aml +++ b/data_model/project_db_schema.aml @@ -87,6 +87,7 @@ Grunderfassung_Dokumente speicher_pfad text dateiname_original text dateigroesse_bytes int nullable + datum_hinzugefuegt "timestamp with time zone" mime_type text anmerkung text diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 50a5a85..dd98820 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -282,7 +282,7 @@ class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): session: Session id: int | None pers_id: int | None - datum_erstellt_utc: datetime.datetime = dc.field( + datum_hinzugefuegt: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) ) speicher_pfad: Path @@ -294,6 +294,28 @@ class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): geloescht: bool = False +class Grunderfassung_Dokumente(BaseModel): + docs: list[Grunderfassung_Dokument] + + +class Grunderfassung_Dokument(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + id: int | None + pers_id: int | None + speicher_pfad: Path + dateiname_original: str = "" + datum_hinzugefuegt: AwareDatetime + dateigroesse_bytes: int | None = None + mime_type: str = "" + anmerkung: str = "" + + # default in SQLAlchemy with lambda and timezone-aware datetime + erstellt: AwareDatetime | None = Field(default=None, exclude=True) + aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above + geloescht: AwareDatetime | None = None + + @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -332,7 +354,7 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]): ) anmerkungen: str | None = None rueckmeldung: str | None = None - ist_geloescht: bool = False + geloescht: bool = False locked: bool @@ -667,6 +689,9 @@ class Grunderfassung_Sprachen(BaseModel): SP_datum_nachweis: datetime.date | None = None +# TODO placeholer docuemnt + + # ** Consulting class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 1baa0b8..4ef1d74 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -100,12 +100,17 @@ from wce_crm.data_models import ( Page_NewEntry_State, Session, set_page_state, - translate_pydantic_errors, ) from wce_crm.form_defs import ( FormField, FormFieldType, ) +from wce_crm.gui_components.dialogs import ( + GUI_validation_error_handling, + get_message_box, + get_user_confirmation, + pydantic_validation_error_handling, +) from wce_crm.logging import ( logger_custom_fields, logger_get_data, @@ -4984,7 +4989,7 @@ class Page_Consulting(QWidget): self.STATE.locked = True if update_only: - relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht] + relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.geloescht] rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None] assert len(relevant_gui_states) == len(rel_cons_session_states) for gui_state, pydantic_state in zip( @@ -5097,7 +5102,7 @@ class Page_Consulting(QWidget): for row in self.STATE.cons_sessions: logger_page_consulting.debug("[Consulting Page] Row state:\n%s", row) - if row.beratung_id is None and row.ist_geloescht: + if row.beratung_id is None and row.geloescht: logger_page_consulting.debug( ( "[Consulting Page] Session which was never saved in the database " @@ -5110,7 +5115,7 @@ class Page_Consulting(QWidget): if timestamp_deleted is not None: # if process is deleted, all sessions must be marked as deleted timestamp_deleted_session = timestamp_deleted - elif row.ist_geloescht: + elif row.geloescht: timestamp_deleted_session = datetime.datetime.now(datetime.UTC) cons_session_dict = dc.asdict(row) @@ -5596,9 +5601,11 @@ class Page_Consulting_ConsultingSession(QWidget): "Löschen bestätigen", "Soll der Eintrag wirklich gelöscht werden?", ) - if confirm: - self.STATE.ist_geloescht = True - self.delete_request.emit(self) + if not confirm: + return + + self.STATE.geloescht = True + self.delete_request.emit(self) def _sync_state_to_GUI( self, @@ -5863,7 +5870,7 @@ class Page_Consulting_Table(QWidget): # states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht] # for row_state in states_to_show: for row_state in self.STATE.row_states: - if not row_state.ist_geloescht: + if not row_state.geloescht: self.add_row(row_state, True) if section is None or section == "locking": @@ -6009,80 +6016,6 @@ def clear_layout( clear_layout(child.layout()) -def get_message_box( - msg_type: QMessageBox.Icon, - title: str, - message: str, - detailed_text: str | None = None, -) -> QMessageBox: - msg_box = QMessageBox() - msg_box.setIcon(msg_type) - msg_box.setWindowTitle(title) - msg_box.setText(message) - if detailed_text is not None: - msg_box.setDetailedText(detailed_text) - - return msg_box - - -def get_user_confirmation( - parent_widget: QWidget, - title: str, - message: str, -) -> bool: - response = QMessageBox.question( - parent_widget, - title, - message, - buttons=(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No), - defaultButton=QMessageBox.StandardButton.No, - ) - - if response == QMessageBox.StandardButton.Yes: - return True - else: - return False - - -def pydantic_validation_error_handling( - val_error: ValidationError, -) -> None: - error_texts: list[str] = [] - translated_errors = translate_pydantic_errors(val_error.errors()) - - for error in translated_errors: - error_field = str(error["loc"][0]) - reason = error["msg"] - path = " → ".join(error["loc"][:-1]) # type: ignore - - error_texts.append(f"- {error_field}: {reason}, (Pfad: {path})") - - msg_box = get_message_box( - QMessageBox.Icon.Warning, - "Fehler bei der Validierung der Eingabedaten", - ( - "Bei der Validierung der Eingaben ist ein Fehler aufgetreten. Details " - "sind unten angefügt." - ), - "\n".join(error_texts), - ) - msg_box.exec() - - -def GUI_validation_error_handling( - errors: list[str], -) -> None: - error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join( - errors - ) - msg_box = get_message_box( - QMessageBox.Icon.Warning, - "Fehlende oder fehlerhafte Angaben", - error_text, - ) - msg_box.exec() - - class _ModifyButton(QPushButton): modify_activated = Signal() modify_deactivated = Signal() diff --git a/src/wce_crm/gui_components/dialogs.py b/src/wce_crm/gui_components/dialogs.py new file mode 100644 index 0000000..c952182 --- /dev/null +++ b/src/wce_crm/gui_components/dialogs.py @@ -0,0 +1,78 @@ +from pydantic import ValidationError +from PySide6.QtWidgets import QMessageBox, QWidget + +from wce_crm.data_models import translate_pydantic_errors + + +def get_user_confirmation( + parent_widget: QWidget, + title: str, + message: str, +) -> bool: + response = QMessageBox.question( + parent_widget, + title, + message, + buttons=(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No), + defaultButton=QMessageBox.StandardButton.No, + ) + + if response == QMessageBox.StandardButton.Yes: + return True + else: + return False + + +def get_message_box( + msg_type: QMessageBox.Icon, + title: str, + message: str, + detailed_text: str | None = None, +) -> QMessageBox: + msg_box = QMessageBox() + msg_box.setIcon(msg_type) + msg_box.setWindowTitle(title) + msg_box.setText(message) + if detailed_text is not None: + msg_box.setDetailedText(detailed_text) + + return msg_box + + +def pydantic_validation_error_handling( + val_error: ValidationError, +) -> None: + error_texts: list[str] = [] + translated_errors = translate_pydantic_errors(val_error.errors()) + + for error in translated_errors: + error_field = str(error["loc"][0]) + reason = error["msg"] + path = " → ".join(error["loc"][:-1]) # type: ignore + + error_texts.append(f"- {error_field}: {reason}, (Pfad: {path})") + + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Fehler bei der Validierung der Eingabedaten", + ( + "Bei der Validierung der Eingaben ist ein Fehler aufgetreten. Details " + "sind unten angefügt." + ), + "\n".join(error_texts), + ) + msg_box.exec() + + +def GUI_validation_error_handling( + errors: list[str], +) -> None: + error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join( + errors + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Fehlende oder fehlerhafte Angaben", + error_text, + ) + msg_box.exec() diff --git a/src/wce_crm/gui_components/initrec_file_selection.py b/src/wce_crm/gui_components/initrec_file_selection.py index 04c5867..67cdb60 100644 --- a/src/wce_crm/gui_components/initrec_file_selection.py +++ b/src/wce_crm/gui_components/initrec_file_selection.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses as dc import datetime import os import sys @@ -36,11 +37,14 @@ from PySide6.QtWidgets import ( import wce_crm.constants from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( + Grunderfassung_Dokument, + Grunderfassung_Dokumente, Page_InitRec_DocumentSection_Doc_State, Page_InitRec_DocumentSection_State, Session, set_page_state, ) +from wce_crm.gui_components.dialogs import get_user_confirmation from wce_crm.logging import logger_gui as logger if TYPE_CHECKING: @@ -246,7 +250,7 @@ class FileItemWidget(QFrame): self.btn_delete.setToolTip("Datei entfernen") self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_delete.setFixedWidth(36) - self.btn_delete.clicked.connect(lambda: self.request_remove.emit(self)) + self.btn_delete.clicked.connect(self._on_delete_btn_clicked) layout.addWidget(self.btn_preview) layout.addWidget(self.btn_delete) @@ -260,13 +264,26 @@ class FileItemWidget(QFrame): self._sync_state_to_GUI(None) + def _on_delete_btn_clicked(self) -> None: + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + + if not confirm: + return + + self.STATE.geloescht = True + self.request_remove.emit(self) + def _sync_state_to_GUI( self, section: Literal["locking", "content"] | None, ) -> None: if section is None or section == "content": - created_at_string = self.STATE.datum_erstellt_utc.astimezone( + created_at_string = self.STATE.datum_hinzugefuegt.astimezone( TIMEZONE_CEST ).strftime("%d.%m.%Y %H:%M") @@ -356,7 +373,7 @@ class DocumentSectionWidget(QWidget): # Dropzone at the top self.drop_zone = _DropZoneWidget() - self.drop_zone.files_dropped.connect(self._add_files) + self.drop_zone.files_dropped.connect(self.add_new_rows) main_layout.addWidget(self.drop_zone) # list containing the file items @@ -383,7 +400,7 @@ class DocumentSectionWidget(QWidget): self.remove_all_rows() for doc in self.STATE.docs: if not doc.geloescht: - self.add_row(doc, True) + self._add_row(doc, True) self.count_changed.emit(self.STATE.doc_count) @@ -450,21 +467,12 @@ class DocumentSectionWidget(QWidget): # !! the row is never None, there are no 'empty' rows # TODO - def add_row( + def _add_row( self, - row_state: Page_InitRec_DocumentSection_Doc_State | None, + row_state: Page_InitRec_DocumentSection_Doc_State, state_exists: bool, ) -> None: - if row_state is None: - new_state = Page_InitRec_DocumentSection_Doc_State( - session=self.STATE.session, - id=None, - pers_id=self.STATE.pers_id, - speicher_pfad=None, - locked=self.STATE.locked, - ) - - item_widget = FileItemWidget(new_state) + item_widget = FileItemWidget(row_state) item_widget.request_remove.connect(self._remove_row) # embed in QListWidget list_item = QListWidgetItem(self.file_list_widget) @@ -478,55 +486,29 @@ class DocumentSectionWidget(QWidget): self.STATE.child_modules.append(item_widget) if not state_exists: - self.STATE.docs.append(new_state) + self.STATE.docs.append(row_state) + # TODO change trigger self.STATE.doc_count = self.file_list_widget.count() - def _add_files( - self, - filepaths: Iterable[Path], - ): - for path in filepaths: - # create custom row widget - new_state = Page_InitRec_DocumentSection_Doc_State( - session=self.STATE.session, - id=None, - pers_id=self.STATE.pers_id, - speicher_pfad=path, - locked=self.STATE.locked, - ) - self.STATE.docs.append(new_state) - item_widget = FileItemWidget(new_state) - item_widget.request_remove.connect(self._remove_row) - self.STATE.child_modules.append(item_widget) - - # embed in QListWidget - list_item = QListWidgetItem(self.file_list_widget) - # min size to avoid cropping of text - size_hint = item_widget.sizeHint() - size_hint.setHeight(size_hint.height() + 4) - list_item.setSizeHint(size_hint) - - self.file_list_widget.addItem(list_item) - self.file_list_widget.setItemWidget(list_item, item_widget) - - self.STATE.doc_count = self.file_list_widget.count() - self.count_changed.emit(self.STATE.doc_count) - def _remove_row( self, widget: QWidget, ): + # TODO set list item directly when the row is created and reference it back # search and delete row for i in range(self.file_list_widget.count()): item = self.file_list_widget.item(i) if self.file_list_widget.itemWidget(item) == widget: assert isinstance(widget, FileItemWidget) - self.file_list_widget.takeItem(i) + taken_item = self.file_list_widget.takeItem(i) self.STATE.child_modules.remove(widget) + del taken_item + widget.deleteLater() break # do not remove the corresponding state since it is tracked # to be post-processed when the database saving operation is triggered + # TODO change trigger self.STATE.doc_count = self.file_list_widget.count() self.count_changed.emit(self.STATE.doc_count) @@ -539,16 +521,35 @@ class DocumentSectionWidget(QWidget): return self.STATE + def add_new_rows( + self, + filepaths: Iterable[Path], + ): + for path in filepaths: + # create custom row widget + new_state = Page_InitRec_DocumentSection_Doc_State( + session=self.STATE.session, + id=None, + pers_id=self.STATE.pers_id, + speicher_pfad=path, + locked=self.STATE.locked, + ) + self._add_row(new_state, state_exists=False) + + # TODO change trigger + self.STATE.doc_count = self.file_list_widget.count() + self.count_changed.emit(self.STATE.doc_count) + # TODO change to protocol/interface with state - def get_all_documents_data(self): - """Methode zum Auslesen aller angehängten Dateien inkl. Metadaten für das Speichern.""" - documents = [] - for i in range(self.file_list_widget.count()): - item = self.file_list_widget.item(i) - widget = self.file_list_widget.itemWidget(item) - if isinstance(widget, FileItemWidget): - documents.append(widget.get_state()) - return documents + # def get_all_documents_data(self): + # """Methode zum Auslesen aller angehängten Dateien inkl. Metadaten für das Speichern.""" + # documents = [] + # for i in range(self.file_list_widget.count()): + # item = self.file_list_widget.item(i) + # widget = self.file_list_widget.itemWidget(item) + # if isinstance(widget, FileItemWidget): + # documents.append(widget.get_state()) + # return documents class _MainWindow(QMainWindow): @@ -576,14 +577,49 @@ class _MainWindow(QMainWindow): self.doc_section.count_changed.connect(self.update_header) layout.addWidget(self.doc_section) + # --- TEST BUTTONS --- # test button to trigger data gathering (simulation for saving procedure and # backend interaction) + layout_save_load_btn = QHBoxLayout() + layout_save_load_btn.setContentsMargins(0, 0, 0, 0) self.btn_save = QPushButton("Erfassung speichern (Daten auslesen)") self.btn_save.setStyleSheet( "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" ) - self.btn_save.clicked.connect(self.print_backend_data) - layout.addWidget(self.btn_save) + self.btn_save.clicked.connect(self.get_current_widget_state) + self.btn_load = QPushButton("Zustand initialisieren (Daten laden)") + self.btn_load.setStyleSheet( + "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" + ) + self.btn_load.clicked.connect(self.load_given_state) + + layout_save_load_btn.addWidget(self.btn_save) + layout_save_load_btn.addWidget(self.btn_load) + layout.addLayout(layout_save_load_btn) + + layout_lock_btn = QHBoxLayout() + layout_lock_btn.setContentsMargins(0, 0, 0, 0) + btn_lock = QPushButton("Form sperren") + btn_lock.setStyleSheet( + "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" + ) + btn_lock.clicked.connect(lambda: self.locking_form(True)) + btn_unlock = QPushButton("Form entsperren") + btn_unlock.setStyleSheet( + "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" + ) + btn_unlock.clicked.connect(lambda: self.locking_form(False)) + + layout_lock_btn.addWidget(btn_lock) + layout_lock_btn.addWidget(btn_unlock) + layout.addLayout(layout_lock_btn) + + btn_pyd_val = QPushButton("Pydantic Validierung") + btn_pyd_val.setStyleSheet( + "background-color: #0078D4; color: white; padding: 8px; font-weight: bold;" + ) + btn_pyd_val.clicked.connect(self.pydantic_validate_state) + layout.addWidget(btn_pyd_val) self.setCentralWidget(central_widget) @@ -591,15 +627,103 @@ class _MainWindow(QMainWindow): """update counter in corresponding label, must be mapped to section heading in form""" self.header_label.setText(f"📎 Dokumente & Anhänge ({count})") - def print_backend_data(self): - data = self.doc_section.get_all_documents_data() - print("\n--- DATEN FÜR SPEICHERVORGANG BEREIT ---") - for i, doc in enumerate(data, 1): - print(f"Datei {i}:") - print(f" Pfad: {doc['file_path']}") - print(f" Datum: {doc['created_at']}") - print(f" Kategorie: {doc['category']}") - print(f" Anmerkung: {doc['note']}") + def locking_form( + self, + lock: bool, + ) -> None: + if lock: + self.doc_section.lock() + else: + self.doc_section.unlock() + + def get_current_widget_state(self): + _ = self.doc_section.get_state() + + def pydantic_validate_state(self): + # data = self.doc_section.get_all_documents_data() + state = self.doc_section.get_state() + docs: list[Grunderfassung_Dokument] = [] + for doc in state.docs: + geloescht_dt: datetime.datetime | None = None + if doc.geloescht: + geloescht_dt = datetime.datetime.now(datetime.UTC) + + dump_data = dc.asdict(doc) + dump_data["geloescht"] = geloescht_dt + + validated = Grunderfassung_Dokument(**dump_data) + docs.append(validated) + + validated_section = Grunderfassung_Dokumente(docs=docs) + + logger.debug( + ("[Module -- DocumentSelection] Pydantic validation. Model:\n%s"), + pformat(validated_section.model_dump()), + ) + + def load_given_state(self) -> None: + new_state = Page_InitRec_DocumentSection_State( + session=self.SESSION, + pers_id=None, + docs=[ + Page_InitRec_DocumentSection_Doc_State( + child_modules=[], + session=Session(user_id=42, user_name="TEST-USER"), + id=None, + pers_id=None, + datum_hinzugefuegt=datetime.datetime( + 2026, 7, 31, 6, 38, 7, 120949, tzinfo=datetime.timezone.utc + ), + speicher_pfad=Path("A:/Organisation/Zeiterfassung/Gesamt.xlsx"), + dateiname_original="Gesamt.xlsx", + dateigroesse_bytes=182564, + mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + anmerkung="", + locked=False, + geloescht=True, + ), + Page_InitRec_DocumentSection_Doc_State( + child_modules=[], + session=Session(user_id=42, user_name="TEST-USER"), + id=None, + pers_id=None, + datum_hinzugefuegt=datetime.datetime( + 2026, 7, 31, 6, 39, 46, 903040, tzinfo=datetime.timezone.utc + ), + speicher_pfad=Path( + "B:/projects/WCE-NAFKA/01 Anforderungen/Detaillierte-Übersicht-Inhalte-Unterkategorien.xlsx" + ), + dateiname_original="Detaillierte-Übersicht-Inhalte-Unterkategorien.xlsx", + dateigroesse_bytes=121528, + mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + anmerkung="", + locked=False, + geloescht=False, + ), + Page_InitRec_DocumentSection_Doc_State( + child_modules=[], + session=Session(user_id=42, user_name="TEST-USER"), + id=None, + pers_id=None, + datum_hinzugefuegt=datetime.datetime( + 2026, 7, 31, 6, 39, 46, 909045, tzinfo=datetime.timezone.utc + ), + speicher_pfad=Path( + "B:/projects/WCE-NAFKA/01 Anforderungen/Mockup-CRM.pptx" + ), + dateiname_original="Mockup-CRM.pptx", + dateigroesse_bytes=613242, + mime_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + anmerkung="", + locked=False, + geloescht=False, + ), + ], + locked=False, + geloescht=False, + ) + + self.doc_section.load_state(new_state) def main() -> None: -- 2.34.1 From a6ef3d562ead09222e43315b2677ed0f9625cdc0 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 11:07:03 +0200 Subject: [PATCH 69/74] add DB schema for documents --- ...update_schema_for_documents_of_initial_.py | 52 +++++++++++++++++++ src/wce_crm/db.py | 35 +++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 alembic/versions/f74114e74ad9_update_schema_for_documents_of_initial_.py diff --git a/alembic/versions/f74114e74ad9_update_schema_for_documents_of_initial_.py b/alembic/versions/f74114e74ad9_update_schema_for_documents_of_initial_.py new file mode 100644 index 0000000..d60611e --- /dev/null +++ b/alembic/versions/f74114e74ad9_update_schema_for_documents_of_initial_.py @@ -0,0 +1,52 @@ +"""update schema for documents of initial recording + +Revision ID: f74114e74ad9 +Revises: 5dfa1fc1dfdf +Create Date: 2026-07-31 11:02:57.484651 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "f74114e74ad9" +down_revision: Union[str, Sequence[str], None] = "5dfa1fc1dfdf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "Grunderfassung_Dokumente", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("pers_id", sa.Integer(), nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("speicherpfad", sa.Text(), nullable=False), + sa.Column("dateiname_original", sa.Text(), nullable=False), + sa.Column("dateigroesse_bytes", sa.Integer(), nullable=True), + sa.Column("datum_hinzugefuegt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("mime_type", sa.Text(), nullable=False), + sa.Column("anmerkung", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["pers_id"], + ["Grunderfassung_Personen.pers_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("Grunderfassung_Dokumente") + # ### end Alembic commands ### diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index acc99b6..487b3c4 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -497,6 +497,41 @@ t_zuordnung_personen_unternehmen: Table = Table( ), ) +t_grunderfassung_dokumente: Table = Table( + "Grunderfassung_Dokumente", + MD_MAIN, + Column( + "id", + sa.Integer, + primary_key=True, + autoincrement=True, + ), + Column("pers_id", sa.ForeignKey("Grunderfassung_Personen.pers_id"), nullable=False), + Column( + "erstellt", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "aktualisiert", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ), + Column( + "geloescht", + UTCDateTime, + nullable=True, + ), + Column("speicherpfad", sa.Text, nullable=False), + Column("dateiname_original", sa.Text, nullable=False), + Column("dateigroesse_bytes", sa.Integer, nullable=True), + Column("datum_hinzugefuegt", UTCDateTime, nullable=False), + Column("mime_type", sa.Text, nullable=False), + Column("anmerkung", sa.Text, nullable=False), +) t_beratung_vorgang: Table = Table( "Beratung_Vorgang", -- 2.34.1 From 68bd873838faf4f95095547bc0211f06aad6c490 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 15:31:59 +0200 Subject: [PATCH 70/74] fix db schema --- ...ocuments_of_initial_recording_fix_typo_.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 alembic/versions/8704e976f0ba_documents_of_initial_recording_fix_typo_.py diff --git a/alembic/versions/8704e976f0ba_documents_of_initial_recording_fix_typo_.py b/alembic/versions/8704e976f0ba_documents_of_initial_recording_fix_typo_.py new file mode 100644 index 0000000..0f00c49 --- /dev/null +++ b/alembic/versions/8704e976f0ba_documents_of_initial_recording_fix_typo_.py @@ -0,0 +1,38 @@ +"""documents of initial recording - fix typo in column + +Revision ID: 8704e976f0ba +Revises: f74114e74ad9 +Create Date: 2026-07-31 11:44:08.601177 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8704e976f0ba" +down_revision: Union[str, Sequence[str], None] = "f74114e74ad9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Grunderfassung_Dokumente", schema=None) as batch_op: + batch_op.add_column(sa.Column("speicher_pfad", sa.Text(), nullable=False)) + batch_op.drop_column("speicherpfad") + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Grunderfassung_Dokumente", schema=None) as batch_op: + batch_op.add_column(sa.Column("speicherpfad", sa.TEXT(), nullable=False)) + batch_op.drop_column("speicher_pfad") + + # ### end Alembic commands ### -- 2.34.1 From c91bc9022af2077f68cb7ba6c034e82cceeafef3 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 15:32:47 +0200 Subject: [PATCH 71/74] working example for document management --- deployment/.env | 1 + src/wce_crm/README.md | 1 + src/wce_crm/backend/backend.py | 25 ++- src/wce_crm/backend/document_service.py | 196 ++++++++++++++++++ src/wce_crm/constants.py | 20 +- src/wce_crm/data_models.py | 47 ++--- src/wce_crm/db.py | 2 +- src/wce_crm/gui.py | 178 ++++++++++++++-- .../gui_components/initrec_file_selection.py | 183 +++++++++------- src/wce_crm/logging.py | 9 +- 10 files changed, 525 insertions(+), 137 deletions(-) create mode 100644 src/wce_crm/backend/document_service.py diff --git a/deployment/.env b/deployment/.env index e2b29a0..c87bc30 100644 --- a/deployment/.env +++ b/deployment/.env @@ -2,4 +2,5 @@ DOPT_STOP_FOLDER_NAME=python DOPT_DB_CRM=data/wce_crm.db DOPT_DB_MAIN=data/wce_grunderfassung.db DOPT_PATH_LOGGING=data/logs +DOPT_PATH_FILE_STORAGE=data/files DOPT_ALEMBIC_BASE=python/alembic \ No newline at end of file diff --git a/src/wce_crm/README.md b/src/wce_crm/README.md index 625eb85..f0d36e6 100644 --- a/src/wce_crm/README.md +++ b/src/wce_crm/README.md @@ -5,4 +5,5 @@ - DOPT_DB_CRM: path to CRM database, relative to base path - DOPT_DB_MAIN: path to main database, relative to base path - DOPT_PATH_LOGGING: path to logging folder, relative to base path +- DOPT_PATH_FILE_STORAGE: path to folder for saving files - DOPT_ALEMBIC_BASE: path to all relevant alembic file diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 97a0042..5c2c9c9 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -4,7 +4,7 @@ import copy import datetime from collections.abc import Container, Mapping from pprint import pformat -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, TypeVar, cast import polars as pl import sqlalchemy as sa @@ -12,7 +12,12 @@ from dopt_basics.result_pattern import wrap_result from sqlalchemy.dialects.sqlite import insert as sqlite_insert from wce_crm import db -from wce_crm.constants import TIMEZONE_CEST +from wce_crm.backend.document_service import ( + DocumentRepository, + DocumentService, + FileStorageService, +) +from wce_crm.constants import TIMEZONE_CEST, Config from wce_crm.data_models import ( FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN, @@ -22,7 +27,7 @@ from wce_crm.data_models import ( InitRec, Page_InitRec_Form_Data, ) -from wce_crm.logging import logger_back as logger +from wce_crm.logging import logger_backend as logger from wce_crm.types import ( CompanyInfo, CompanyProfileConsultationEntry, @@ -45,6 +50,11 @@ if TYPE_CHECKING: K = TypeVar("K") V = TypeVar("V") +INITREC_DOC_SERVICE: Final[DocumentService] = DocumentService( + repo=DocumentRepository(file_storage_dir=Config.PATH_FILE_STORAGE), + storage=FileStorageService(storage_dir=Config.PATH_FILE_STORAGE), +) + def _transform_for_gui_output( data: pl.DataFrame, @@ -244,6 +254,10 @@ def page_initrec_to_db( data.form_data.metadata = in_filter data.form_data.person = out_filter + # documents + assert data.ids.pers_id + data.docs = INITREC_DOC_SERVICE.to_db(conn, pers_id=data.ids.pers_id, docs=data.docs) + # company if data.recording_type is RecordingType.WITH_COMPANY: # insert into company table and add additional information @@ -378,11 +392,16 @@ def page_initrec_from_db( "[InitRec -- backend] Loaded entries which seem to be deleted - IDs: %s", ids ) + # documents + with db.ENGINE.connect() as conn: + docs = INITREC_DOC_SERVICE.from_db(conn, pers_id=ids.pers_id) + return InitRec( ids=ids, recording_type=recording_type, geloescht=geloescht, form_data=form_data, + docs=docs, ) diff --git a/src/wce_crm/backend/document_service.py b/src/wce_crm/backend/document_service.py new file mode 100644 index 0000000..b2fd2e1 --- /dev/null +++ b/src/wce_crm/backend/document_service.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import datetime +import shutil +import uuid +from pathlib import Path +from pprint import pformat +from typing import TYPE_CHECKING, Any, Final, cast + +import sqlalchemy as sa + +from wce_crm import db +from wce_crm.constants import TIMEZONE_CEST +from wce_crm.data_models import Grunderfassung_Dokument +from wce_crm.logging import logger_initrec_docs as logger + +if TYPE_CHECKING: + from wce_crm.types import RecId + + +class FileStorageService: + def __init__( + self, + storage_dir: Path, + ): + self.storage_dir = storage_dir + + def store_file( + self, + src_path: Path, + ) -> Path: + """Copies file to the application's file storage, assgn an UUID and gives back + the absolute path.""" + src_path = src_path.resolve() + assert src_path.is_file(), "provided path is not a file" + + if not src_path.exists(): + raise FileNotFoundError( + f"Die Datei {src_path.name} konnte nicht gefunden werden. " + f"Existiert diese noch oder wurde sie gelöscht?" + ) + + ts_now = datetime.datetime.now(tz=TIMEZONE_CEST) + month = str(ts_now.month).zfill(2) + year = str(ts_now.year) + target_dir = self.storage_dir / year / month + target_dir.mkdir(parents=True, exist_ok=True) + + new_filename = f"{uuid.uuid4().hex}{src_path.suffix}" + target_path = target_dir / new_filename + + shutil.copy2(src_path, target_path) + + return target_path + + def delete_file( + self, + absolute_path: Path, + ) -> None: + if absolute_path.is_relative_to(self.storage_dir) and absolute_path.exists(): + absolute_path.unlink() + + +class DocumentRepository: + def __init__( + self, + file_storage_dir: Path, + ): + self.file_storage_dir = file_storage_dir + + def from_db( + self, + conn: sa.Connection, + pers_id: RecId, + ) -> list[Grunderfassung_Dokument]: + stmt = sa.select(db.t_grunderfassung_dokumente).where( + db.t_grunderfassung_dokumente.c.pers_id == pers_id, + db.t_grunderfassung_dokumente.c.geloescht.is_(None), + ) + + ret = conn.execute(stmt) + + docs: list[Grunderfassung_Dokument] = [] + for row in ret.mappings(): + row = dict(row) + row["speicher_pfad"] = self.file_storage_dir / row["speicher_pfad"] + docs.append(Grunderfassung_Dokument(**row)) + + return docs + + def to_db( + self, + conn: sa.Connection, + pers_id: RecId, + docs: list[Grunderfassung_Dokument], + ) -> list[Grunderfassung_Dokument]: + rows_for_db_insert: list[dict[str, Any]] = [] + rows_for_db_update: list[dict[str, Any]] = [] + docs_inserted: list[Grunderfassung_Dokument] = [] + + for doc in docs: + doc.pers_id = pers_id + if doc.id is None: + row_data = doc.model_dump(exclude={"id"}) + rows_for_db_insert.append(row_data) + docs_inserted.append(doc) + else: + row_data = doc.model_dump() + # new bind param to avoid name clashes + row_data["b_id"] = row_data["id"] + del row_data["id"] + rows_for_db_update.append(row_data) + + # prepare path as relative + file_path = row_data.get("speicher_pfad") + if isinstance(file_path, Path): + if file_path.is_absolute(): + file_path = file_path.relative_to(self.file_storage_dir) + row_data["speicher_pfad"] = str(file_path) + + if rows_for_db_update: + # ... update + logger.debug( + "[InitRec -- Docs] Call update for docs:\n%s", pformat(rows_for_db_update) + ) + stmt = db.t_grunderfassung_dokumente.update().where( + db.t_grunderfassung_dokumente.c.id == sa.bindparam("b_id") + ) + conn.execute(stmt, rows_for_db_update) + + if rows_for_db_insert: + # ... insert + logger.debug( + "[InitRec -- Docs] Call insert for docs:\n%s", pformat(rows_for_db_insert) + ) + stmt = db.t_grunderfassung_dokumente.insert().returning( + db.t_grunderfassung_dokumente.c.id + ) + res = conn.execute(stmt, rows_for_db_insert) + new_doc_ids = cast(list[int], [row[0] for row in res.fetchall()]) + + assert len(docs_inserted) == len(new_doc_ids) + for doc, new_id in zip(docs_inserted, new_doc_ids): + doc.id = new_id + + return docs + + +class DocumentService: + def __init__( + self, + repo: DocumentRepository, + storage: FileStorageService, + ): + self.repo = repo + self.storage = storage + + def to_db( + self, + conn: sa.Connection, + pers_id: RecId, + docs: list[Grunderfassung_Dokument], + ) -> list[Grunderfassung_Dokument]: + try: + for doc in docs: + if doc.id is None: + doc.speicher_pfad = self.storage.store_file(doc.speicher_pfad) + except Exception: + logger.error( + "[InitRec -- Docs] Error during file saving procedure", + exc_info=True, + ) + raise + + try: + docs = self.repo.to_db(conn, pers_id=pers_id, docs=docs) + return docs + + except Exception: + logger.error( + "[InitRec -- Docs] Error during DB saving procedure", + exc_info=True, + ) + for doc in docs: + # The delete method checks if the provided saving path is really + # relative to the storage path. Otherwise, no delete is performed to + # ensure that original files are never deleted + self.storage.delete_file(doc.speicher_pfad) + raise + + def from_db( + self, + conn: sa.Connection, + pers_id: RecId, + ) -> list[Grunderfassung_Dokument]: + return self.repo.from_db(conn, pers_id=pers_id) diff --git a/src/wce_crm/constants.py b/src/wce_crm/constants.py index f4f389d..cf7e637 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -19,13 +19,21 @@ assert BASE_PATH class Config: DEVELOPMENT_STATE: bool = bool(os.getenv("DOPT_DEVELOPMENT", None)) - DB_PATH_KONTAKTLISTE: Path = BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db") - DB_PATH_MAIN: Path = BASE_PATH / os.getenv( - "DOPT_DB_MAIN", "data/db/wce_grunderfassung.db" - ) - PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log") + DB_PATH_KONTAKTLISTE: Path = ( + BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db") + ).resolve() + DB_PATH_MAIN: Path = ( + BASE_PATH / os.getenv("DOPT_DB_MAIN", "data/db/wce_grunderfassung.db") + ).resolve() + PATH_LOGGING: Path = (BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/logs")).resolve() + PATH_FILE_STORAGE: Path = ( + BASE_PATH / os.getenv("DOPT_PATH_FILE_STORAGE", "data/files") + ).resolve() LOG_FILENAME: str = "dopt.log" - ALEMBIC_PATH: Path = BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic") + ALEMBIC_PATH: Path = ( + BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic") + ).resolve() TIMEZONE_CEST: Final[zoneinfo.ZoneInfo] = zoneinfo.ZoneInfo("Europe/Berlin") +COLUMN_SEP: Final[str] = "__" diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index dd98820..f95ca2f 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -20,6 +20,7 @@ from pydantic import ( ) from pydantic_core import ErrorDetails +from wce_crm.constants import COLUMN_SEP from wce_crm.form_defs import INITREC_COMP, INITREC_METADATEN, INITREC_PERSON, FormField from wce_crm.types import ConsultingType, EntityIds, EntityType, RecordingType @@ -29,7 +30,6 @@ if TYPE_CHECKING: from wce_crm.types import ConsId, EntityType, RecId, UserId ValidAge = Annotated[int, Field(ge=0, le=99)] -COLUMN_SEP: Final[str] = "__" M = TypeVar("M", bound="Module") S = TypeVar("S", bound="PageState") @@ -224,6 +224,7 @@ class Page_InitRec_State(PageState["InitRecForm"]): initrec_form_states: dict[EntityType, Page_InitRec_Form_State] = dc.field( init=False, default_factory=dict ) + docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list) def __post_init__(self) -> None: relevant_entities = frozenset( @@ -270,7 +271,6 @@ class Page_InitRec_Form_State(PageState[Module]): @dc.dataclass(slots=True, kw_only=True) class Page_InitRec_DocumentSection_State(PageState["FileItemWidget"]): session: Session - pers_id: int | None doc_count: int = 0 docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list) locked: bool @@ -281,7 +281,7 @@ class Page_InitRec_DocumentSection_State(PageState["FileItemWidget"]): class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): session: Session id: int | None - pers_id: int | None + # pers_id: int | None datum_hinzugefuegt: datetime.datetime = dc.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) ) @@ -294,28 +294,6 @@ class Page_InitRec_DocumentSection_Doc_State(PageState[Module]): geloescht: bool = False -class Grunderfassung_Dokumente(BaseModel): - docs: list[Grunderfassung_Dokument] - - -class Grunderfassung_Dokument(BaseModel): - model_config = ConfigDict(str_strip_whitespace=True) - - id: int | None - pers_id: int | None - speicher_pfad: Path - dateiname_original: str = "" - datum_hinzugefuegt: AwareDatetime - dateigroesse_bytes: int | None = None - mime_type: str = "" - anmerkung: str = "" - - # default in SQLAlchemy with lambda and timezone-aware datetime - erstellt: AwareDatetime | None = Field(default=None, exclude=True) - aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above - geloescht: AwareDatetime | None = None - - @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session @@ -485,11 +463,30 @@ class FlatBaseModel(BaseModel): # ** InitRec +class Grunderfassung_Dokument(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + id: int | None + pers_id: int | None + speicher_pfad: Path + dateiname_original: str + datum_hinzugefuegt: AwareDatetime + dateigroesse_bytes: int | None = None + mime_type: str + anmerkung: str + + # default in SQLAlchemy with lambda and timezone-aware datetime + erstellt: AwareDatetime | None = Field(default=None, exclude=True) + aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above + geloescht: AwareDatetime | None = None + + class InitRec(BaseModel): ids: EntityIds recording_type: RecordingType geloescht: AwareDatetime | None = None form_data: Page_InitRec_Form_Data + docs: list[Grunderfassung_Dokument] # mapping between initrec form field keys and database properties diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 487b3c4..fb3f03d 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -525,7 +525,7 @@ t_grunderfassung_dokumente: Table = Table( UTCDateTime, nullable=True, ), - Column("speicherpfad", sa.Text, nullable=False), + Column("speicher_pfad", sa.Text, nullable=False), Column("dateiname_original", sa.Text, nullable=False), Column("dateigroesse_bytes", sa.Integer, nullable=True), Column("datum_hinzugefuegt", UTCDateTime, nullable=False), diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 4ef1d74..f5f27e2 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -82,17 +82,19 @@ from PySide6.QtWidgets import ( import wce_crm.constants from wce_crm import types from wce_crm.backend import backend -from wce_crm.constants import TIMEZONE_CEST +from wce_crm.constants import COLUMN_SEP, TIMEZONE_CEST from wce_crm.data_models import ( - COLUMN_SEP, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, + Grunderfassung_Dokument, InitRec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, Page_Consulting_State, Page_Consulting_Table_State, + Page_InitRec_DocumentSection_Doc_State, + Page_InitRec_DocumentSection_State, Page_InitRec_Form_Data, Page_InitRec_Form_State, Page_InitRec_State, @@ -111,6 +113,7 @@ from wce_crm.gui_components.dialogs import ( get_user_confirmation, pydantic_validation_error_handling, ) +from wce_crm.gui_components.initrec_file_selection import DocumentSectionWidget from wce_crm.logging import ( logger_custom_fields, logger_get_data, @@ -3083,12 +3086,17 @@ CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { class CollapsibleBox(QWidget): - def __init__(self, title="", parent=None): + def __init__( + self, + title: str = "", + parent: QWidget | None = None, + ) -> None: super().__init__(parent) # 1. Header-Button mit Pfeil self.toggle_button = QToolButton() - self.toggle_button.setText(f" {title}") + self.base_text = f" {title}" + self.toggle_button.setText(self.base_text) self.toggle_button.setCheckable(True) self.toggle_button.setChecked(False) self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) @@ -3108,7 +3116,10 @@ class CollapsibleBox(QWidget): main_layout.addWidget(self.toggle_button) main_layout.addWidget(self.content_area) - def _on_toggle(self, checked: bool): + def _on_toggle( + self, + checked: bool, + ) -> None: # Pfeilrichtung ändern & Inhalt ein-/ausblenden self.toggle_button.setArrowType( Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow @@ -3118,18 +3129,25 @@ class CollapsibleBox(QWidget): def setContentLayout( self, layout: QLayout, - ): + ) -> None: self.content_area.setLayout(layout) def setContentWidget( self, widget: QWidget, - ): + ) -> None: layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Keine zusätzlichen Ränder layout.addWidget(widget) self.setContentLayout(layout) + def set_additional_text( + self, + text: str, + ) -> None: + new_txt = self.base_text + text + self.toggle_button.setText(new_txt) + def set_open( self, open: bool, @@ -3362,13 +3380,30 @@ class Page_InitRec(QWidget): QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) + # ** documents + self.docs_box = CollapsibleBox("Dokumente") + docs_widget_state = Page_InitRec_DocumentSection_State( + session=self.STATE.session, + docs=self.STATE.docs, + locked=self.STATE.locked, + geloescht=self.STATE.geloescht, + ) + self.docs_widget = DocumentSectionWidget(docs_widget_state) + self.docs_box.setContentWidget(self.docs_widget) + self.container_layout.addWidget(self.docs_box) + self.docs_widget.doc_count_changed.connect(self._trigger_update_docs_box_title) + self.container_layout.addStretch() self._update_layout() def _debug_print_state(self) -> None: - logger_initrecform.info( + self._sync_GUI_to_state() + logger_page_initrec.info( "[Page -- InitRec] Printing state IDs:\n%s", pformat(self.STATE.ids) ) + logger_page_initrec.info( + "[Page -- InitRec] Printing state Docs:\n%s", pformat(self.STATE.docs) + ) def _request_company_profile(self) -> None: assert self.STATE.ids.un_id is not None, ( @@ -3378,9 +3413,16 @@ class Page_InitRec(QWidget): session=self.STATE.session, un_id=self.STATE.ids.un_id, ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + logger_page_initrec.debug("[Page -- InitRec] State to call: %s", req_state) self.company_profile_requested.emit(req_state) + def _trigger_update_docs_box_title( + self, + count: int, + ) -> None: + add_txt = f" ({count})" + self.docs_box.set_additional_text(add_txt) + def _update_layout(self) -> None: self.initrec_form_metadata_box.set_open(True) if self.STATE.recording_type is RecordingType.WITH_COMPANY: @@ -3518,6 +3560,7 @@ class Page_InitRec(QWidget): def _reset(self) -> None: for form in self.STATE.child_modules: form.reset_form() + self.STATE.docs.clear() self.STATE.ids = EntityIds() self.STATE.geloescht = False @@ -3543,7 +3586,6 @@ class Page_InitRec(QWidget): logger_initrecform.debug("[Page -- InitRec] Loading: No ID set. Do nothing!") return - # TODO add loading logic with backend call initrec = backend.page_initrec_from_db(ids) # TODO re-enable later @@ -3612,6 +3654,31 @@ class Page_InitRec(QWidget): case _: raise RuntimeError(f"Unspecified entity type {ent_type}") + if update_only: + relevant_gui_states = [s for s in self.STATE.docs if not s.geloescht] + rel_doc_states = [s for s in data.docs if s.geloescht is None] + assert len(relevant_gui_states) == len(rel_doc_states) + for gui_state, pydantic_state in zip(relevant_gui_states, rel_doc_states): + gui_state.id = pydantic_state.id + gui_state.speicher_pfad = pydantic_state.speicher_pfad + else: + self.STATE.docs.clear() + for pydantic_state in data.docs: + # Deleted sessions are not part of the retrieved data from the backend if this + # page is initialised from a fresh backend call to get data. (Updates do + # indeed have data which must be filtered -- see above) + doc_state = Page_InitRec_DocumentSection_Doc_State( + session=self.STATE.session, + id=pydantic_state.id, + speicher_pfad=pydantic_state.speicher_pfad, + dateiname_original=pydantic_state.dateiname_original, + datum_hinzugefuegt=pydantic_state.datum_hinzugefuegt, + mime_type=pydantic_state.mime_type, + anmerkung=pydantic_state.anmerkung, + locked=self.STATE.locked, + ) + self.STATE.docs.append(doc_state) + if sync_to_GUI: self._sync_state_to_GUI(None) @@ -3630,6 +3697,14 @@ class Page_InitRec(QWidget): self._reset() self._update_layout() + doc_widget_state = Page_InitRec_DocumentSection_State( + session=self.STATE.session, + docs=self.STATE.docs, + locked=self.STATE.locked, + geloescht=self.STATE.geloescht, + ) + self.docs_widget.load_state(doc_widget_state) + if section is None or section == "banners": self._clear_info_banners() if self.STATE.ids.pers_id is None: @@ -3642,10 +3717,12 @@ class Page_InitRec(QWidget): if self.STATE.locked: for form in self.STATE.child_modules: form.lock() + self.docs_widget.lock() self.edit_buttons.set_modify_state(False, set_delete_btn=True) else: for form in self.STATE.child_modules: form.unlock() + self.docs_widget.unlock() self.edit_buttons.set_modify_state(True, set_delete_btn=False) self._activate_delete() @@ -3693,6 +3770,9 @@ class Page_InitRec(QWidget): case _: raise RuntimeError(f"Unspecified entity type {form_state.ent_type}") + docs_widget_state = self.docs_widget.get_state() + self.STATE.docs = docs_widget_state.docs + logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: IDs after...\n%s", pformat(self.STATE.ids), @@ -3729,6 +3809,8 @@ class Page_InitRec(QWidget): errors.extend(m.validate()) + errors.extend(self.docs_widget.validate()) + return errors def lock(self) -> None: @@ -3763,13 +3845,35 @@ class Page_InitRec(QWidget): "[Page -- InitRec] This was saved in the database " "and is marked for deletion - fast path." ) - deleted_datetime = datetime.datetime.now(datetime.UTC) + ts_deleted = datetime.datetime.now(datetime.UTC) assert self.STATE.form_data, "cannot delete entry with uninitialised form data" + + pydantic_docs: list[Grunderfassung_Dokument] = [] + for doc_state in self.STATE.docs: + if doc_state.id is None: + logger_page_initrec.debug( + ( + "[Page -- InitRec] State which was never saved in the database " + "and is marked for deletion - ignore." + ) + ) + continue + + doc_state_dict = dc.asdict(doc_state) + del doc_state_dict["geloescht"] + doc = Grunderfassung_Dokument( + geloescht=ts_deleted, + pers_id=self.STATE.ids.pers_id, + **doc_state_dict, + ) + pydantic_docs.append(doc) + data_to_db = InitRec( ids=self.STATE.ids, recording_type=self.STATE.recording_type, - geloescht=deleted_datetime, + geloescht=ts_deleted, form_data=db_data, + docs=pydantic_docs, ) returned = backend.page_initrec_to_db(data_to_db) self._load_from_pydantic(returned) @@ -3817,6 +3921,45 @@ class Page_InitRec(QWidget): case _: raise RuntimeError(f"Unspecified entity type {ent_type}") + ts_deleted: datetime.datetime | None = None + if self.STATE.geloescht: + ts_deleted = datetime.datetime.now(datetime.UTC) + + pydantic_docs: list[Grunderfassung_Dokument] = [] + for doc_state in self.STATE.docs: + if doc_state.id is None and doc_state.geloescht: + logger_page_initrec.debug( + ( + "[Page -- InitRec] State which was never saved in the database " + "and is marked for deletion - ignore." + ) + ) + continue + + ts_deleted_doc: datetime.datetime | None = None + if ts_deleted is not None: + # if process is deleted, all sessions must be marked as deleted + ts_deleted_doc = ts_deleted + elif doc_state.geloescht: + ts_deleted_doc = datetime.datetime.now(datetime.UTC) + + doc_state_dict = dc.asdict(doc_state) + del doc_state_dict["geloescht"] + doc = Grunderfassung_Dokument( + geloescht=ts_deleted_doc, + pers_id=self.STATE.ids.pers_id, + **doc_state_dict, + ) + pydantic_docs.append(doc) + + data_to_db = InitRec( + ids=self.STATE.ids, + recording_type=self.STATE.recording_type, + geloescht=ts_deleted, + form_data=db_data, + docs=pydantic_docs, + ) + except ValidationError as e: logger_page_initrec.error( "[Page -- InitRec] Error during Pydantic validation phase:\n%s", @@ -3834,19 +3977,10 @@ class Page_InitRec(QWidget): "[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), ) - deleted_datetime: datetime.datetime | None = None - if self.STATE.geloescht: - deleted_datetime = datetime.datetime.now(datetime.UTC) + try: # TODO change to result pattern - data_to_db = InitRec( - ids=self.STATE.ids, - recording_type=self.STATE.recording_type, - geloescht=deleted_datetime, - form_data=db_data, - ) returned = backend.page_initrec_to_db(data_to_db) - self._load_from_pydantic(returned, update_only=True, sync_to_GUI=False) self.update_triggered.emit() except Exception as err: diff --git a/src/wce_crm/gui_components/initrec_file_selection.py b/src/wce_crm/gui_components/initrec_file_selection.py index 67cdb60..db5f6cf 100644 --- a/src/wce_crm/gui_components/initrec_file_selection.py +++ b/src/wce_crm/gui_components/initrec_file_selection.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Literal from PySide6.QtCore import ( QFileInfo, QMimeDatabase, + QSignalBlocker, Qt, QUrl, Signal, @@ -38,7 +39,6 @@ import wce_crm.constants from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( Grunderfassung_Dokument, - Grunderfassung_Dokumente, Page_InitRec_DocumentSection_Doc_State, Page_InitRec_DocumentSection_State, Session, @@ -91,6 +91,15 @@ class _DropZoneWidget(QFrame): border-color: #0078D4; background-color: #F0F6FF; } + /* optics in locked state */ + _DropZoneWidget:disabled { + border: 2px dashed #D0D0D0; + background-color: #F2F2F2; + } + _DropZoneWidget:disabled { + border: 2px dashed #D0D0D0; + background-color: #F2F2F2; + } """ STYLE_DRAG_ACTIVE = """ @@ -119,10 +128,25 @@ class _DropZoneWidget(QFrame): self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.label) + def changeEvent( + self, + event, + ) -> None: + if event.type() == event.Type.EnabledChange: + if not self.isEnabled(): + self.label.setText("🔒 Dateiupload deaktiviert (Formular gesperrt)") + self.setCursor(Qt.CursorShape.ForbiddenCursor) + else: + self.label.setText( + "📁 Dateien hierher ziehen oder klicken zum Auswählen" + ) + self.setCursor(Qt.CursorShape.PointingHandCursor) + super().changeEvent(event) + def dragEnterEvent( self, event: QDragEnterEvent, - ): + ) -> None: if event.mimeData().hasUrls(): # check if MDB/explorer gives at least one real file (no folder) has_files = False @@ -136,10 +160,16 @@ class _DropZoneWidget(QFrame): event.acceptProposedAction() self.setStyleSheet(self.STYLE_DRAG_ACTIVE) - def dragLeaveEvent(self, event): + def dragLeaveEvent( + self, + event, + ) -> None: self.setStyleSheet(self.STYLE_NORMAL) - def dropEvent(self, event: QDropEvent): + def dropEvent( + self, + event: QDropEvent, + ) -> None: self.dragLeaveEvent(None) valid_files: list[Path] = [] @@ -153,7 +183,10 @@ class _DropZoneWidget(QFrame): if valid_files: self.files_dropped.emit(valid_files) - def mousePressEvent(self, event): + def mousePressEvent( + self, + event, + ) -> None: if event.button() == Qt.MouseButton.LeftButton: files, _ = QFileDialog.getOpenFileNames(self, "Dokumente auswählen") if files: @@ -167,21 +200,31 @@ class FileItemWidget(QFrame): def __init__( self, state: Page_InitRec_DocumentSection_Doc_State, + qt_list_item: QListWidgetItem, parent: QWidget | None = None, - ): + ) -> None: super().__init__(parent) self.STATE = state + self.qt_list_item = qt_list_item self.setStyleSheet(""" - _FileItemWidget { + FileItemWidget { background-color: #FFFFFF; border: 1px solid #E0E0E0; border-radius: 6px; padding: 6px; } - _FileItemWidget:hover { + FileItemWidget:hover { border-color: #B0B0B0; } + /* optics in locked state */ + FileItemWidget:disabled { + background-color: #F8F8F8; + border-color: #E5E5E5; + } + FileItemWidget QLabel:disabled { + color: #777777; + } """) layout = QHBoxLayout(self) @@ -224,7 +267,7 @@ class FileItemWidget(QFrame): layout.addLayout(info_vbox, stretch=2) # category dropdown: not needed by WCE, but still included to re-enable later - # TODO maybe add later + # ?? maybe add later self.category_combo = QComboBox() # self.category_combo.addItems( # [ @@ -292,11 +335,13 @@ class FileItemWidget(QFrame): ) self.icon_label.setPixmap(file_icon.pixmap(28, 28)) - self.STATE.dateiname_original = self.STATE.speicher_pfad.name + if not self.STATE.dateiname_original: + self.STATE.dateiname_original = self.STATE.speicher_pfad.name self.STATE.mime_type = get_mime_type(self.STATE.speicher_pfad) try: - self.STATE.dateigroesse_bytes = self.STATE.speicher_pfad.stat().st_size + if not self.STATE.dateigroesse_bytes or self.STATE.dateigroesse_bytes == 0: + self.STATE.dateigroesse_bytes = self.STATE.speicher_pfad.stat().st_size self.file_size_display = _format_file_size(self.STATE.dateigroesse_bytes) except OSError: self.file_size_display = "Unbekannt" @@ -308,6 +353,7 @@ class FileItemWidget(QFrame): f"{created_at_string}" ) ) + print(f"\n\n\n {self.STATE.anmerkung=} \n {self.STATE} \n\n\n") self.note_edit.setText(self.STATE.anmerkung) if section is None or section == "locking": @@ -358,13 +404,13 @@ class FileItemWidget(QFrame): class DocumentSectionWidget(QWidget): """main widget for file selection""" - count_changed = Signal(int) # sends current number of files contained by the widget + doc_count_changed = Signal(int) # sends current number of files contained by the widget def __init__( self, state: Page_InitRec_DocumentSection_State, parent: QWidget | None = None, - ): + ) -> None: super().__init__(parent) self.STATE = state @@ -393,24 +439,29 @@ class DocumentSectionWidget(QWidget): main_layout.addWidget(self.file_list_widget) - def _sync_state_to_GUI(self, section: Literal["content", "locking"] | None) -> None: + def _sync_state_to_GUI( + self, + section: Literal["content", "locking"] | None, + ) -> None: self.setUpdatesEnabled(False) if section is None or section == "content": - self.remove_all_rows() - for doc in self.STATE.docs: - if not doc.geloescht: - self._add_row(doc, True) - - self.count_changed.emit(self.STATE.doc_count) + with QSignalBlocker(self): + self._remove_all_rows() + for doc in self.STATE.docs: + if not doc.geloescht: + self.add_row(doc, True) + self.doc_count_changed.emit(self.STATE.doc_count) if section is None or section == "locking": if self.STATE.locked: for m in self.STATE.child_modules: m.lock() + self.drop_zone.setEnabled(False) else: for m in self.STATE.child_modules: m.unlock() + self.drop_zone.setEnabled(True) self.setUpdatesEnabled(True) @@ -461,21 +512,20 @@ class DocumentSectionWidget(QWidget): def save_data(self) -> None: pass - def remove_all_rows(self) -> None: + def _remove_all_rows(self) -> None: while self.STATE.child_modules: self._remove_row(self.STATE.child_modules[0]) - # !! the row is never None, there are no 'empty' rows - # TODO - def _add_row( + def add_row( self, row_state: Page_InitRec_DocumentSection_Doc_State, state_exists: bool, ) -> None: - item_widget = FileItemWidget(row_state) - item_widget.request_remove.connect(self._remove_row) # embed in QListWidget list_item = QListWidgetItem(self.file_list_widget) + item_widget = FileItemWidget(row_state, list_item) + item_widget.request_remove.connect(self._remove_row) + # min size to avoid cropping of text size_hint = item_widget.sizeHint() size_hint.setHeight(size_hint.height() + 4) @@ -488,29 +538,23 @@ class DocumentSectionWidget(QWidget): if not state_exists: self.STATE.docs.append(row_state) - # TODO change trigger self.STATE.doc_count = self.file_list_widget.count() + self.doc_count_changed.emit(self.STATE.doc_count) def _remove_row( self, - widget: QWidget, + widget: FileItemWidget, ): - # TODO set list item directly when the row is created and reference it back - # search and delete row - for i in range(self.file_list_widget.count()): - item = self.file_list_widget.item(i) - if self.file_list_widget.itemWidget(item) == widget: - assert isinstance(widget, FileItemWidget) - taken_item = self.file_list_widget.takeItem(i) - self.STATE.child_modules.remove(widget) - del taken_item - widget.deleteLater() - break + row = self.file_list_widget.row(widget.qt_list_item) + + if row >= 0: + self.file_list_widget.takeItem(row) + self.STATE.child_modules.remove(widget) + widget.deleteLater() # do not remove the corresponding state since it is tracked # to be post-processed when the database saving operation is triggered - # TODO change trigger self.STATE.doc_count = self.file_list_widget.count() - self.count_changed.emit(self.STATE.doc_count) + self.doc_count_changed.emit(self.STATE.doc_count) def get_state(self) -> Page_InitRec_DocumentSection_State: self._sync_GUI_to_state() @@ -524,38 +568,26 @@ class DocumentSectionWidget(QWidget): def add_new_rows( self, filepaths: Iterable[Path], - ): - for path in filepaths: - # create custom row widget - new_state = Page_InitRec_DocumentSection_Doc_State( - session=self.STATE.session, - id=None, - pers_id=self.STATE.pers_id, - speicher_pfad=path, - locked=self.STATE.locked, - ) - self._add_row(new_state, state_exists=False) + ) -> None: + with QSignalBlocker(self): + for path in filepaths: + # create custom row widget + new_state = Page_InitRec_DocumentSection_Doc_State( + session=self.STATE.session, + id=None, + speicher_pfad=path, + locked=self.STATE.locked, + ) + self.add_row(new_state, state_exists=False) - # TODO change trigger self.STATE.doc_count = self.file_list_widget.count() - self.count_changed.emit(self.STATE.doc_count) - - # TODO change to protocol/interface with state - # def get_all_documents_data(self): - # """Methode zum Auslesen aller angehängten Dateien inkl. Metadaten für das Speichern.""" - # documents = [] - # for i in range(self.file_list_widget.count()): - # item = self.file_list_widget.item(i) - # widget = self.file_list_widget.itemWidget(item) - # if isinstance(widget, FileItemWidget): - # documents.append(widget.get_state()) - # return documents + self.doc_count_changed.emit(self.STATE.doc_count) class _MainWindow(QMainWindow): """main window for internal test only, not the this module's component""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.setWindowTitle("Prototyp: Dokumente & Anhänge") self.resize(650, 500) @@ -570,11 +602,9 @@ class _MainWindow(QMainWindow): # custom widget self.SESSION = Session(user_id=42, user_name="TEST-USER") - state = Page_InitRec_DocumentSection_State( - session=self.SESSION, pers_id=None, locked=False - ) + state = Page_InitRec_DocumentSection_State(session=self.SESSION, locked=False) self.doc_section = DocumentSectionWidget(state) - self.doc_section.count_changed.connect(self.update_header) + self.doc_section.doc_count_changed.connect(self.update_header) layout.addWidget(self.doc_section) # --- TEST BUTTONS --- @@ -623,7 +653,10 @@ class _MainWindow(QMainWindow): self.setCentralWidget(central_widget) - def update_header(self, count): + def update_header( + self, + count: int, + ) -> None: """update counter in corresponding label, must be mapped to section heading in form""" self.header_label.setText(f"📎 Dokumente & Anhänge ({count})") @@ -654,23 +687,21 @@ class _MainWindow(QMainWindow): validated = Grunderfassung_Dokument(**dump_data) docs.append(validated) - validated_section = Grunderfassung_Dokumente(docs=docs) + validated_section = [m.model_dump() for m in docs] logger.debug( ("[Module -- DocumentSelection] Pydantic validation. Model:\n%s"), - pformat(validated_section.model_dump()), + pformat(validated_section), ) def load_given_state(self) -> None: new_state = Page_InitRec_DocumentSection_State( session=self.SESSION, - pers_id=None, docs=[ Page_InitRec_DocumentSection_Doc_State( child_modules=[], session=Session(user_id=42, user_name="TEST-USER"), id=None, - pers_id=None, datum_hinzugefuegt=datetime.datetime( 2026, 7, 31, 6, 38, 7, 120949, tzinfo=datetime.timezone.utc ), @@ -686,7 +717,6 @@ class _MainWindow(QMainWindow): child_modules=[], session=Session(user_id=42, user_name="TEST-USER"), id=None, - pers_id=None, datum_hinzugefuegt=datetime.datetime( 2026, 7, 31, 6, 39, 46, 903040, tzinfo=datetime.timezone.utc ), @@ -704,7 +734,6 @@ class _MainWindow(QMainWindow): child_modules=[], session=Session(user_id=42, user_name="TEST-USER"), id=None, - pers_id=None, datum_hinzugefuegt=datetime.datetime( 2026, 7, 31, 6, 39, 46, 909045, tzinfo=datetime.timezone.utc ), diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index 7c12017..ed322c1 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -47,11 +47,14 @@ logger_page_initrec = logger_gui.getChild("page_initrec") logger_page_initrec.setLevel(logging.DEBUG) logger_initrecform = logger_gui.getChild("initrecform") -logger_initrecform.setLevel(logging.DEBUG) +logger_initrecform.setLevel(logging.WARNING) logger_prototyping = logger_gui.getChild("prototyping") logger_prototyping.setLevel(logging.DEBUG) # ** Backend -logger_back = logger_base.getChild("backend") -logger_back.setLevel(logging.DEBUG) +logger_backend = logger_base.getChild("backend") +logger_backend.setLevel(logging.WARNING) + +logger_initrec_docs = logger_base.getChild("initrec_docs") +logger_initrec_docs.setLevel(logging.DEBUG) -- 2.34.1 From 37f75356939ae409098589c51266d6f14ecadc3e Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 15:55:25 +0200 Subject: [PATCH 72/74] update docs --- README.md | 12 ++++++++++-- docs/Versionshistorie.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 925ae5c..af005d7 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,18 @@ Die Anwendung besteht aus einem Ordner, der alle relevanten Dateien zu ihrer Aus ## Verwendung -In der aktuellen Version ist die Grunderfassung für Unternehmen implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann die Grunderfassung für Unternehmen gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz unten gespeichert oder zurückgesetzt werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen. +### Grunderfassung + +In der aktuellen Version ist die Grunderfassung implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann der Typ der Grunderfassung gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz oben gespeichert oder zurückgesetzt werden. Hierfür muss vorther die Bearbeitung aktiviert werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen. Einträge können auch gelöscht werden. Sind alle Felder gefüllt, werden im Hintergrund die Daten nach dem Auslösen des Speichervorgangs nochmals validiert. Beispielsweise werden erfasste E-Mail-Adressen auf Gültigkeit geprüft. Etwaige Fehler werden dem Nutzer ebenfalls angezeigt. Nach der erfolgreichen Speicherung kann über die Schaltfläche im oberen Bereich "zurück zur Übersicht" auf die Hauptseite gewechselt werden. Dort sollte nun der neu angelegte Eintrag sichtbar sein. Die Übersicht ist absteigend nach dem Datum der letzten Änderung sortiert. Ein eben gespeicherter Eintrag sollte sich demzufolge ganz oben in der Übersicht befinden. Mit einem Klick auf einen Eintrag in der Übersicht wird erneut das Formular zur Grunderfassung mit den hinterlegten Daten geladen und angezeigt. Es können Änderungen vorgenommen und gespeichert werden. -*Die Grunderfassung für Individualpersonen ist aktuell nur als Dummy verfügbar. Bei einem Klick auf die entsprechende Schaltfläche passiert nichts.* +### Unternehmesprofil + +Wird eine Grunderfassung mit Unternehmen durchgeführt, so kann für dieses Unternehmen auf das Unternehmensprofil über die Schaltfläche im oberen Bereich gewechselt werden. Aktuell beherbergt dieses Profil die Stammdaten als auch die Verwaltung von Pauschal- und Indivualberatungen. + +### Beratungen + +Über die Startseite und die Schaltfläche "Neu" sowie die Auswahl im Unternehmesprofil kann ein Beratungsvorgang angelegt werden. Es kann zwischen Pauschal- und Individualberatungen ausgewählt werden. Je nach Kontext ist die Beratung bereits mit Entitäten verknüpft. Ausstehende Verknüpfungen werden nach dem Speichern der Beratung dem Nutzer überein Banner mitgeteilt. Er hat dann die Möglichkeit, die Verknüpfung von Unternehmen und/oder Personen vorzunehmen. diff --git a/docs/Versionshistorie.md b/docs/Versionshistorie.md index d2bda5c..e9825af 100644 --- a/docs/Versionshistorie.md +++ b/docs/Versionshistorie.md @@ -1,5 +1,34 @@ # Versionshistorie (Changelog) +## 31.07.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260731) + +- Umsetzung der folgenden Anforderungen (IDs): + - 1000 + - 1002 + - 1003 + - 1004 + - 1005 + - 1006 + - 1007 + - 1008 + - 1009 + - 1011 + - 1012 + - 1013 + - 1014 +- Grunderfassung komplett überarbeitet: + - mehr Übersicht durch aufklappbare Bereiche + - Hinzufügen und Verwalten von Dokumenten + - weitere gemäß Anforderungsliste +- Unternehmensprofil: + - hinzugefügt + - Möglichkeit zur Anlage von Beratungsvorgängen +- Beratungsvorgänge: + - Anlegen über Startseite "Neu" und über Unternehmensprofil + - nachträgliche Verknüpfung mit Unternehmen/Personen (kontextabhängig) + - Hinzufügen/Verwalten mehrerer Sessions + - Platzhalter für Verlinkung in CRM-Matrix (mehrere Elemente sollen später ausgewählt werden) + ## 17.06.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260617) - Grunderfassung für Individualpersonen hinzugefügt -- 2.34.1 From f8b1c65221271b03e0a9b9564eba3951955e52d7 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 16:04:15 +0200 Subject: [PATCH 73/74] update build pipeline and deployment config --- deployment/.env | 4 ++-- scripts/build.ps1 | 15 +++++---------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/deployment/.env b/deployment/.env index c87bc30..8efb520 100644 --- a/deployment/.env +++ b/deployment/.env @@ -1,6 +1,6 @@ DOPT_STOP_FOLDER_NAME=python -DOPT_DB_CRM=data/wce_crm.db -DOPT_DB_MAIN=data/wce_grunderfassung.db +DOPT_DB_CRM=data/db/wce_crm.db +DOPT_DB_MAIN=data/db/wce_grunderfassung.db DOPT_PATH_LOGGING=data/logs DOPT_PATH_FILE_STORAGE=data/files DOPT_ALEMBIC_BASE=python/alembic \ No newline at end of file diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 55424fb..13a79f0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -45,15 +45,10 @@ Write-Output "Deleted outstanding artifacts successfully" Write-Output "Create data folder..." $pattern = "dopt_nafka_wce-crm_v*" -$data_folder = Join-Path -Path $ENV_PATH -ChildPath 'data' -if (-not (Test-Path -Path $data_folder)){ - Write-Output "[PWSH] Data path not existing. Create..." - New-Item -Path $data_folder -ItemType Directory -} -else { - Write-Output "Data path already exists." -} +create_folder -base_path $ENV_PATH -folder_name 'data' -recreate +create_folder -base_path $ENV_PATH -folder_name 'data/db' +create_folder -base_path $ENV_PATH -folder_name 'data/files' Write-Output "Building package..." @@ -93,10 +88,10 @@ if ($? -eq $false){ } Write-Output "Successfully installed package" -# copy database file +# copy database files Write-Output "Copying database files..." $copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/db/wce_grunderfassung.db' -$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data' +$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/db' Copy-Item -Path $copy_file -Destination $dest_file -Force if ($? -eq $false){ Write-Output "[PWSH] Exiting script because there were errors while copying database 'Grunderfassung'" -- 2.34.1 From 36b7c319cca2db67e70159e56b53580f5ff122a1 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 16:18:10 +0200 Subject: [PATCH 74/74] update build pipeline and version --- pyproject.toml | 4 ++-- scripts/build.ps1 | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 04176df..500fc03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wce-crm" -version = "0.1.1dev10" +version = "0.1.1dev11" description = "GUI for CRM of NAFKA project with WCE" authors = [ {name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, @@ -79,7 +79,7 @@ directory = "reports/coverage" [tool.bumpversion] -current_version = "0.1.1dev10" +current_version = "0.1.1dev11" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 13a79f0..928a109 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -105,6 +105,17 @@ if ($? -eq $false){ } Write-Output "Copied database files successfully" +# copy content files +Write-Output "Copying content files (documents)..." +$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/files' +$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/' +Copy-Item -Path $copy_file -Destination $dest_file -Force -Recurse +if ($? -eq $false){ + Write-Output "[PWSH] Exiting script because there were errors while copying documents" + Exit +} +Write-Output "Copied content files (documents) successfully" + # copy alembic files Write-Output "Copying alembic files..." $copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic' -- 2.34.1