From 07ae18271eabe6f191b40845a432a0fcf4470f25 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 30 Jul 2026 15:05:56 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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