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()