document selection: add Pydantic models and tests for saving/loading

This commit is contained in:
2026-07-31 09:35:49 +02:00
parent 1c586299fc
commit ecb9a9cf13
5 changed files with 314 additions and 153 deletions
+1
View File
@@ -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
+27 -2
View File
@@ -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)
+15 -82
View File
@@ -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()
+78
View File
@@ -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()
@@ -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"<b>📎 Dokumente & Anhänge ({count})</b>")
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: