From ecb9a9cf1329652df360eb870bf96964732384c1 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 09:35:49 +0200 Subject: [PATCH] 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: