From c91bc9022af2077f68cb7ba6c034e82cceeafef3 Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 31 Jul 2026 15:32:47 +0200 Subject: [PATCH] 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)