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 ###
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/data_model/project_db_schema.aml b/data_model/project_db_schema.aml
index 7b10d53..df381e6 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,20 @@ 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_bytes int nullable
+ datum_hinzugefuegt "timestamp with time zone"
+ mime_type text
+ anmerkung text
Beratung_Vorgang | Beratung kann an Unternehmen allein (pauschal), Person allein (pauschal) oder beide (individual) gebunden sein
@@ -93,7 +101,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/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/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/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 8c9f695..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_CRM: 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 4a3f578..f95ca2f 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
@@ -19,15 +20,16 @@ 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
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)]
-COLUMN_SEP: Final[str] = "__"
M = TypeVar("M", bound="Module")
S = TypeVar("S", bound="PageState")
@@ -222,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(
@@ -265,6 +268,32 @@ class Page_InitRec_Form_State(PageState[Module]):
geloescht: bool = False
+@dc.dataclass(slots=True, kw_only=True)
+class Page_InitRec_DocumentSection_State(PageState["FileItemWidget"]):
+ session: Session
+ doc_count: int = 0
+ docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list)
+ locked: bool
+ geloescht: bool = False
+
+
+@dc.dataclass(slots=True, kw_only=True)
+class Page_InitRec_DocumentSection_Doc_State(PageState[Module]):
+ session: Session
+ id: int | None
+ # pers_id: int | None
+ datum_hinzugefuegt: datetime.datetime = dc.field(
+ default_factory=lambda: datetime.datetime.now(datetime.UTC)
+ )
+ speicher_pfad: Path
+ dateiname_original: str = ""
+ dateigroesse_bytes: int | None = None
+ mime_type: str = ""
+ anmerkung: str = ""
+ locked: bool
+ geloescht: bool = False
+
+
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_State(PageState[Module]):
session: Session
@@ -303,7 +332,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
@@ -434,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
@@ -638,6 +686,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/db.py b/src/wce_crm/db.py
index 2b4b231..fb3f03d 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",
@@ -486,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("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),
+ Column("mime_type", sa.Text, nullable=False),
+ Column("anmerkung", sa.Text, nullable=False),
+)
t_beratung_vorgang: Table = Table(
"Beratung_Vorgang",
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..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,
@@ -100,12 +102,18 @@ 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.gui_components.initrec_file_selection import DocumentSectionWidget
from wce_crm.logging import (
logger_custom_fields,
logger_get_data,
@@ -130,7 +138,6 @@ if TYPE_CHECKING:
)
from wce_crm.types import (
ConsId,
- ExtMaId,
LinkingConsultationsEntryCompany,
LinkingConsultationsEntryPerson,
)
@@ -3079,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)
@@ -3104,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
@@ -3114,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,
@@ -3256,7 +3278,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)
@@ -3358,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, (
@@ -3374,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:
@@ -3477,6 +3523,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(
@@ -3499,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
@@ -3524,8 +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
- # !! just a dummy
initrec = backend.page_initrec_from_db(ids)
# TODO re-enable later
@@ -3594,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)
@@ -3612,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:
@@ -3624,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()
@@ -3675,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),
@@ -3711,6 +3809,8 @@ class Page_InitRec(QWidget):
errors.extend(m.validate())
+ errors.extend(self.docs_widget.validate())
+
return errors
def lock(self) -> None:
@@ -3745,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)
@@ -3799,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",
@@ -3816,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:
@@ -4589,6 +4741,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
@@ -4970,7 +5123,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(
@@ -5083,7 +5236,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 "
@@ -5096,7 +5249,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)
@@ -5582,9 +5735,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,
@@ -5611,7 +5766,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()
@@ -5849,7 +6004,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":
@@ -5995,80 +6150,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()
@@ -6165,7 +6246,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 +6266,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 +6304,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 +6586,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
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
new file mode 100644
index 0000000..db5f6cf
--- /dev/null
+++ b/src/wce_crm/gui_components/initrec_file_selection.py
@@ -0,0 +1,771 @@
+from __future__ import annotations
+
+import dataclasses as dc
+import datetime
+import os
+import sys
+from collections.abc import Iterable
+from pathlib import Path
+from pprint import pformat
+from typing import TYPE_CHECKING, Literal
+
+from PySide6.QtCore import (
+ QFileInfo,
+ QMimeDatabase,
+ QSignalBlocker,
+ 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,
+)
+
+import wce_crm.constants
+from wce_crm.constants import TIMEZONE_CEST
+from wce_crm.data_models import (
+ Grunderfassung_Dokument,
+ 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:
+ 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,
+) -> 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: int,
+) -> str:
+ """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;
+ }
+ /* 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 = """
+ _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 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
+ 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,
+ ) -> None:
+ self.setStyleSheet(self.STYLE_NORMAL)
+
+ def dropEvent(
+ self,
+ event: QDropEvent,
+ ) -> None:
+ 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,
+ ) -> None:
+ 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,
+ 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 {
+ background-color: #FFFFFF;
+ border: 1px solid #E0E0E0;
+ border-radius: 6px;
+ padding: 6px;
+ }
+ 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)
+ layout.setContentsMargins(8, 6, 8, 6)
+
+ # file icon: use dummy to avoid icons with checkmarks (synchronised folder)
+ 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.file_size_display: str = ""
+
+ 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()
+ 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(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
+ # ?? 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(self._on_delete_btn_clicked)
+
+ layout.addWidget(self.btn_preview)
+ layout.addWidget(self.btn_delete)
+
+ 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)
+
+ 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_hinzugefuegt.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))
+
+ 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:
+ 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"
+
+ self.name_label.setText(f"{self.STATE.dateiname_original}")
+ self.meta_label.setText(
+ (
+ f"{self.file_size_display} • Hinzugefügt: "
+ 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":
+ 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):
+ """main widget for file selection"""
+
+ 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
+
+ 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_new_rows)
+ 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 _sync_state_to_GUI(
+ self,
+ section: Literal["content", "locking"] | None,
+ ) -> None:
+ self.setUpdatesEnabled(False)
+
+ if section is None or section == "content":
+ 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)
+
+ 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,
+ state_exists: bool,
+ ) -> None:
+ # 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)
+ 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(row_state)
+
+ self.STATE.doc_count = self.file_list_widget.count()
+ self.doc_count_changed.emit(self.STATE.doc_count)
+
+ def _remove_row(
+ self,
+ widget: FileItemWidget,
+ ):
+ 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
+ self.STATE.doc_count = self.file_list_widget.count()
+ self.doc_count_changed.emit(self.STATE.doc_count)
+
+ 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
+
+ def add_new_rows(
+ self,
+ filepaths: Iterable[Path],
+ ) -> 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)
+
+ self.STATE.doc_count = self.file_list_widget.count()
+ 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) -> None:
+ 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.SESSION = Session(user_id=42, user_name="TEST-USER")
+ state = Page_InitRec_DocumentSection_State(session=self.SESSION, locked=False)
+ self.doc_section = DocumentSectionWidget(state)
+ self.doc_section.doc_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.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)
+
+ 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})")
+
+ 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 = [m.model_dump() for m in docs]
+
+ logger.debug(
+ ("[Module -- DocumentSelection] Pydantic validation. Model:\n%s"),
+ pformat(validated_section),
+ )
+
+ def load_given_state(self) -> None:
+ new_state = Page_InitRec_DocumentSection_State(
+ session=self.SESSION,
+ docs=[
+ Page_InitRec_DocumentSection_Doc_State(
+ child_modules=[],
+ session=Session(user_id=42, user_name="TEST-USER"),
+ 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,
+ 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,
+ 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:
+ 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/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)