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)