diff --git a/README.md b/README.md index 925ae5c..af005d7 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,18 @@ Die Anwendung besteht aus einem Ordner, der alle relevanten Dateien zu ihrer Aus ## Verwendung -In der aktuellen Version ist die Grunderfassung für Unternehmen implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann die Grunderfassung für Unternehmen gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz unten gespeichert oder zurückgesetzt werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen. +### Grunderfassung + +In der aktuellen Version ist die Grunderfassung implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann der Typ der Grunderfassung gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz oben gespeichert oder zurückgesetzt werden. Hierfür muss vorther die Bearbeitung aktiviert werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen. Einträge können auch gelöscht werden. Sind alle Felder gefüllt, werden im Hintergrund die Daten nach dem Auslösen des Speichervorgangs nochmals validiert. Beispielsweise werden erfasste E-Mail-Adressen auf Gültigkeit geprüft. Etwaige Fehler werden dem Nutzer ebenfalls angezeigt. Nach der erfolgreichen Speicherung kann über die Schaltfläche im oberen Bereich "zurück zur Übersicht" auf die Hauptseite gewechselt werden. Dort sollte nun der neu angelegte Eintrag sichtbar sein. Die Übersicht ist absteigend nach dem Datum der letzten Änderung sortiert. Ein eben gespeicherter Eintrag sollte sich demzufolge ganz oben in der Übersicht befinden. Mit einem Klick auf einen Eintrag in der Übersicht wird erneut das Formular zur Grunderfassung mit den hinterlegten Daten geladen und angezeigt. Es können Änderungen vorgenommen und gespeichert werden. -*Die Grunderfassung für Individualpersonen ist aktuell nur als Dummy verfügbar. Bei einem Klick auf die entsprechende Schaltfläche passiert nichts.* +### Unternehmesprofil + +Wird eine Grunderfassung mit Unternehmen durchgeführt, so kann für dieses Unternehmen auf das Unternehmensprofil über die Schaltfläche im oberen Bereich gewechselt werden. Aktuell beherbergt dieses Profil die Stammdaten als auch die Verwaltung von Pauschal- und Indivualberatungen. + +### Beratungen + +Über die Startseite und die Schaltfläche "Neu" sowie die Auswahl im Unternehmesprofil kann ein Beratungsvorgang angelegt werden. Es kann zwischen Pauschal- und Individualberatungen ausgewählt werden. Je nach Kontext ist die Beratung bereits mit Entitäten verknüpft. Ausstehende Verknüpfungen werden nach dem Speichern der Beratung dem Nutzer überein Banner mitgeteilt. Er hat dann die Möglichkeit, die Verknüpfung von Unternehmen und/oder Personen vorzunehmen. diff --git a/alembic/env.py b/alembic/env.py index 02f88a9..4e18418 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -46,6 +46,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + render_as_batch=True, ) with context.begin_transaction(): @@ -66,7 +67,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py b/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py new file mode 100644 index 0000000..b88c4e6 --- /dev/null +++ b/alembic/versions/03d4f000f445_add_unique_constraint_for_company_.py @@ -0,0 +1,33 @@ +"""add unique constraint for company person linking table + +Revision ID: 03d4f000f445 +Revises: 85577ce9f2ab +Create Date: 2026-07-22 09:17:46.576581 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "03d4f000f445" +down_revision: Union[str, Sequence[str], None] = "85577ce9f2ab" +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("zuordnung_personen_unternehmen") as batch_op: + batch_op.create_unique_constraint("uq_un_id_pers_id", ["un_id", "pers_id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen") as batch_op: + batch_op.drop_constraint("uq_un_id_pers_id") + # ### end Alembic commands ### diff --git a/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py b/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py new file mode 100644 index 0000000..83e0c40 --- /dev/null +++ b/alembic/versions/1a8e957522a4_baseline_data_model_with_new_approach.py @@ -0,0 +1,118 @@ +"""Baseline: data model with new approach + +Revision ID: 1a8e957522a4 +Revises: +Create Date: 2026-06-26 13:47:48.937123 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "1a8e957522a4" +down_revision: Union[str, Sequence[str], None] = None +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_personen", + sa.Column("pers_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("Metadaten_erstellung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_aktualisierung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.Date(), nullable=True), + sa.Column("Arbeitserfahrung", sa.Text(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("HoehereBildung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.Text(), nullable=True), + sa.Column("Schulbildung", sa.Text(), nullable=True), + sa.Column("Sprachkenntnisse", sa.Text(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.Text(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.Text(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.Text(), nullable=True), + sa.Column("Stammdaten__land", sa.Text(), nullable=True), + sa.Column("Stammdaten__email", sa.Text(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.Text(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.Date(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.Text(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__name", sa.Text(), nullable=True), + sa.Column("Stammdaten__ort", sa.Text(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.Boolean(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.Text(), nullable=True), + sa.Column("Stammdaten__strasse", sa.Text(), nullable=True), + sa.Column("Stammdaten__titel", sa.Text(), nullable=True), + sa.Column("Stammdaten__vorname", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + op.create_table( + "grunderfassung_unternehmen", + sa.Column("un_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("Metadaten_erstellung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_aktualisierung", wce_crm.db.UTCDateTime(), nullable=True), + sa.Column("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.Text(), nullable=True), + sa.Column("Partnersuche__person_suche", sa.Integer(), nullable=True), + sa.Column("Partnersuche__un_suche", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "zuordnung_personen_unternehmen", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=False), + sa.Column("pers_id", sa.Integer(), nullable=False), + sa.Column("gueltig_ab", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("gueltig_bis", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("zuordnung_personen_unternehmen") + op.drop_table("grunderfassung_unternehmen") + op.drop_table("grunderfassung_personen") + # ### end Alembic commands ### diff --git a/alembic/versions/2afa4e506448_adding_consulting_sessions.py b/alembic/versions/2afa4e506448_adding_consulting_sessions.py new file mode 100644 index 0000000..cc27941 --- /dev/null +++ b/alembic/versions/2afa4e506448_adding_consulting_sessions.py @@ -0,0 +1,73 @@ +"""adding consulting sessions + +Revision ID: 2afa4e506448 +Revises: 1a8e957522a4 +Create Date: 2026-07-09 14:21:24.586706 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "2afa4e506448" +down_revision: Union[str, Sequence[str], None] = "1a8e957522a4" +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( + "beratung_vorgang", + sa.Column("vorgang_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=True), + sa.Column("pers_id", sa.Integer(), nullable=True), + sa.Column("titel", sa.Text(), nullable=False), + sa.Column("beratungs_typ", sa.String(length=20), nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "beratung_einzelberatung", + sa.Column("beratung_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("vorgang_id", sa.Integer(), nullable=False), + sa.Column("nutzer_id", sa.Integer(), nullable=False), + sa.Column("nutzer_name", sa.String(length=20), nullable=False), + sa.Column("zeitstempel", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("ansprechpartner", sa.Text(), nullable=False), + sa.Column("kommunikationsweg", sa.Text(), nullable=False), + sa.Column("thema_crm_matrix", sa.Text(), nullable=True), + sa.Column("anmerkungen", sa.Text(), nullable=True), + sa.Column("rueckmeldung", sa.Text(), nullable=True), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("aktualisiert", wce_crm.db.UTCDateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["vorgang_id"], + ["beratung_vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("beratung_einzelberatung") + op.drop_table("beratung_vorgang") + # ### end Alembic commands ### diff --git a/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py b/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py new file mode 100644 index 0000000..24e62ec --- /dev/null +++ b/alembic/versions/5dfa1fc1dfdf_update_schema_to_meet_customer_.py @@ -0,0 +1,48 @@ +"""update schema to meet customer requirements + +Revision ID: 5dfa1fc1dfdf +Revises: e290fe18132d +Create Date: 2026-07-28 08:39:44.168872 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5dfa1fc1dfdf" +down_revision: Union[str, Sequence[str], None] = "e290fe18132d" +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_Personen", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Stammdaten__kanal_aufmerksamkeit", sa.Text(), nullable=True) + ) + + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column(sa.Column("Partnersuche__kontaktweg", sa.Text(), nullable=True)) + batch_op.drop_column("Partnersuche__kanal_aufmerksamkeit") + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True) + ) + batch_op.drop_column("Partnersuche__kontaktweg") + + with op.batch_alter_table("Grunderfassung_Personen", schema=None) as batch_op: + batch_op.drop_column("Stammdaten__kanal_aufmerksamkeit") + + # ### end Alembic commands ### diff --git a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py deleted file mode 100644 index 4ec9390..0000000 --- a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py +++ /dev/null @@ -1,91 +0,0 @@ -"""added new column and renamed table for initial recording - -Revision ID: 5f2af5179c47 -Revises: -Create Date: 2026-06-17 16:06:46.421562 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "5f2af5179c47" -down_revision: Union[str, Sequence[str], None] = None -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.rename_table("grunderfassung_unternehmen", "grunderfassung") - op.add_column( - "grunderfassung", - sa.Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "grunderfassung_unternehmen", - sa.Column("erfassung_id", sa.INTEGER(), nullable=False), - sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), - sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), - sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), - sa.Column("Arbeitserfahrung", sa.TEXT(), nullable=True), - sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), - sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), - sa.Column("HoehereBildung", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_adresse", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_anrede_anschrift", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_email", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_festnetznummer", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_funktion_beziehung", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_mobilfunknummer", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_name", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_name_partner", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_titel", sa.TEXT(), nullable=True), - sa.Column("Kontaktperson__KP_vorname", sa.TEXT(), nullable=True), - sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True), - sa.Column("Partnersuche__person_suche", sa.INTEGER(), nullable=True), - sa.Column("Partnersuche__un_suche", sa.INTEGER(), nullable=True), - sa.Column("Projektrelevanz__relevanz", sa.TEXT(), nullable=True), - sa.Column("Projektrelevanz__foerderperiode", sa.TEXT(), nullable=True), - sa.Column("Schulbildung", sa.TEXT(), nullable=True), - sa.Column("Sprachkenntnisse", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__PLZ", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anrede_anschrift", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anzahl_kinder__alter", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.INTEGER(), nullable=True), - sa.Column("Stammdaten__aufenthaltsort", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__bundesland", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__land", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__email", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__familienstand", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__festnetznummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__geburtsdatum", sa.DATE(), nullable=True), - sa.Column("Stammdaten__hausnummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__herkunftsland", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__mobilfunknummer", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__name", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__ort", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__rueckkehrer", sa.BOOLEAN(), nullable=True), - sa.Column("Stammdaten__staatsangehoerigkeit", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__strasse", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__titel", sa.TEXT(), nullable=True), - sa.Column("Stammdaten__vorname", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_arbeitsstatus", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_deutsch_sprache", sa.TEXT(), nullable=True), - sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.DATE(), nullable=True), - sa.Column("WeitereInfos__WI_meldung_institution", sa.TEXT(), nullable=True), - sa.PrimaryKeyConstraint("erfassung_id"), - ) - op.drop_table("grunderfassung") - # ### end Alembic commands ### diff --git a/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py b/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py new file mode 100644 index 0000000..dbd9743 --- /dev/null +++ b/alembic/versions/665259cdfdc0_change_unique_constraint_to_partial_.py @@ -0,0 +1,47 @@ +"""change unique constraint to partial index for company person linking table + +Revision ID: 665259cdfdc0 +Revises: 03d4f000f445 +Create Date: 2026-07-22 10:14:43.889298 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "665259cdfdc0" +down_revision: Union[str, Sequence[str], None] = "03d4f000f445" +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("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f("uq_un_id_pers_id"), type_="unique") + batch_op.create_index( + "uq_aktive_zuordnung", + ["un_id", "pers_id"], + unique=True, + sqlite_where=sa.text("gueltig_bis IS NULL"), + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_index( + "uq_aktive_zuordnung", sqlite_where=sa.text("gueltig_bis IS NULL") + ) + batch_op.create_unique_constraint( + batch_op.f("uq_un_id_pers_id"), ["un_id", "pers_id"] + ) + + # ### end Alembic commands ### diff --git a/alembic/versions/8000d15faf03_major_change_in_data_model.py b/alembic/versions/8000d15faf03_major_change_in_data_model.py new file mode 100644 index 0000000..5242e0a --- /dev/null +++ b/alembic/versions/8000d15faf03_major_change_in_data_model.py @@ -0,0 +1,317 @@ +"""major change in data model + +Revision ID: 8000d15faf03 +Revises: 665259cdfdc0 +Create Date: 2026-07-24 15:13:57.443684 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "8000d15faf03" +down_revision: Union[str, Sequence[str], None] = "665259cdfdc0" +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.drop_table("grunderfassung_personen") + op.drop_table("beratung_vorgang") + op.drop_table("grunderfassung_unternehmen") + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.drop_index( + batch_op.f("uq_aktive_zuordnung"), sqlite_where=sa.text("gueltig_bis IS NULL") + ) + + op.drop_table("zuordnung_personen_unternehmen") + op.drop_table("beratung_einzelberatung") + + op.create_table( + "Grunderfassung_Personen", + sa.Column("pers_id", sa.Integer(), autoincrement=True, 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("Metadaten_nutzer", sa.String(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.Date(), nullable=True), + sa.Column("Arbeitserfahrung", sa.Text(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.Text(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.Text(), nullable=True), + sa.Column("HoehereBildung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.Text(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.Text(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.Text(), nullable=True), + sa.Column("Schulbildung", sa.Text(), nullable=True), + sa.Column("Sprachkenntnisse", sa.Text(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.Text(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.Text(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.Text(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.Text(), nullable=True), + sa.Column("Stammdaten__land", sa.Text(), nullable=True), + sa.Column("Stammdaten__email", sa.Text(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.Text(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.Date(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.Text(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.Text(), nullable=True), + sa.Column("Stammdaten__name", sa.Text(), nullable=True), + sa.Column("Stammdaten__ort", sa.Text(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.Boolean(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.Text(), nullable=True), + sa.Column("Stammdaten__strasse", sa.Text(), nullable=True), + sa.Column("Stammdaten__titel", sa.Text(), nullable=True), + sa.Column("Stammdaten__vorname", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.Text(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + op.create_table( + "Unternehmen", + sa.Column("un_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("erstellt", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "Beratung_Vorgang", + sa.Column("vorgang_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=True), + sa.Column("pers_id", sa.Integer(), nullable=True), + sa.Column("titel", sa.Text(), nullable=False), + sa.Column("beratungs_typ", sa.String(length=20), 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.ForeignKeyConstraint( + ["pers_id"], + ["Grunderfassung_Personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["Unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "Personen_Unternehmen_Zuordnung", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("un_id", sa.Integer(), nullable=False), + sa.Column("an_id", sa.Integer(), 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.ForeignKeyConstraint( + ["pers_id"], + ["Grunderfassung_Personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["Unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.create_index( + "uq_aktive_zuordnung", + ["un_id", "pers_id"], + unique=True, + sqlite_where=sa.text("geloescht IS NULL"), + ) + + op.create_table( + "Beratung_Einzelberatung", + sa.Column("beratung_id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("vorgang_id", sa.Integer(), nullable=False), + sa.Column("nutzer_id", sa.Integer(), nullable=False), + sa.Column("nutzer_name", sa.String(length=20), nullable=False), + sa.Column("zeitstempel", wce_crm.db.UTCDateTime(), nullable=False), + sa.Column("ansprechpartner", sa.Text(), nullable=False), + sa.Column("kommunikationsweg", sa.Text(), nullable=False), + sa.Column("thema_crm_matrix", sa.Text(), nullable=True), + sa.Column("anmerkungen", sa.Text(), nullable=True), + sa.Column("rueckmeldung", sa.Text(), nullable=True), + 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.ForeignKeyConstraint( + ["vorgang_id"], + ["Beratung_Vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("Beratung_Einzelberatung") + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.drop_index("uq_aktive_zuordnung", sqlite_where=sa.text("geloescht IS NULL")) + + op.drop_table("Personen_Unternehmen_Zuordnung") + op.drop_table("Beratung_Vorgang") + op.drop_table("Unternehmen") + op.drop_table("Grunderfassung_Personen") + + op.create_table( + "beratung_einzelberatung", + sa.Column("beratung_id", sa.INTEGER(), nullable=False), + sa.Column("vorgang_id", sa.INTEGER(), nullable=False), + sa.Column("nutzer_id", sa.INTEGER(), nullable=False), + sa.Column("nutzer_name", sa.VARCHAR(length=20), nullable=False), + sa.Column("zeitstempel", sa.DATETIME(), nullable=False), + sa.Column("ansprechpartner", sa.TEXT(), nullable=False), + sa.Column("kommunikationsweg", sa.TEXT(), nullable=False), + sa.Column("thema_crm_matrix", sa.TEXT(), nullable=True), + sa.Column("anmerkungen", sa.TEXT(), nullable=True), + sa.Column("rueckmeldung", sa.TEXT(), nullable=True), + sa.Column("erstellt", sa.DATETIME(), nullable=False), + sa.Column("aktualisiert", sa.DATETIME(), nullable=False), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["vorgang_id"], + ["beratung_vorgang.vorgang_id"], + ), + sa.PrimaryKeyConstraint("beratung_id"), + ) + op.create_table( + "zuordnung_personen_unternehmen", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("un_id", sa.INTEGER(), nullable=False), + sa.Column("pers_id", sa.INTEGER(), nullable=False), + sa.Column("gueltig_ab", sa.DATETIME(), nullable=False), + sa.Column("gueltig_bis", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("zuordnung_personen_unternehmen", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("uq_aktive_zuordnung"), + ["un_id", "pers_id"], + unique=1, + sqlite_where=sa.text("gueltig_bis IS NULL"), + ) + + op.create_table( + "grunderfassung_unternehmen", + sa.Column("un_id", sa.INTEGER(), nullable=False), + sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__person_suche", sa.INTEGER(), nullable=True), + sa.Column("Partnersuche__un_suche", sa.INTEGER(), nullable=True), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("un_id"), + ) + op.create_table( + "beratung_vorgang", + sa.Column("vorgang_id", sa.INTEGER(), nullable=False), + sa.Column("un_id", sa.INTEGER(), nullable=True), + sa.Column("pers_id", sa.INTEGER(), nullable=True), + sa.Column("titel", sa.TEXT(), nullable=False), + sa.Column("beratungs_typ", sa.VARCHAR(length=20), nullable=False), + sa.Column("erstellt", sa.DATETIME(), nullable=False), + sa.Column("aktualisiert", sa.DATETIME(), nullable=False), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["pers_id"], + ["grunderfassung_personen.pers_id"], + ), + sa.ForeignKeyConstraint( + ["un_id"], + ["grunderfassung_unternehmen.un_id"], + ), + sa.PrimaryKeyConstraint("vorgang_id"), + ) + op.create_table( + "grunderfassung_personen", + sa.Column("pers_id", sa.INTEGER(), nullable=False), + sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), + sa.Column("Metadaten_wiedereintrittsdatum", sa.DATE(), nullable=True), + sa.Column("Arbeitserfahrung", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), + sa.Column("HoehereBildung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.TEXT(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.TEXT(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.TEXT(), nullable=True), + sa.Column("Schulbildung", sa.TEXT(), nullable=True), + sa.Column("Sprachkenntnisse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.INTEGER(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__land", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__email", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.DATE(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__name", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__ort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.BOOLEAN(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__strasse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__titel", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__vorname", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.DATE(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.TEXT(), nullable=True), + sa.Column("geloescht", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("pers_id"), + ) + + # ### end Alembic commands ### diff --git a/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py b/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py new file mode 100644 index 0000000..72acc16 --- /dev/null +++ b/alembic/versions/830416cab29c_timestamps_as_deletion_indicator.py @@ -0,0 +1,41 @@ +"""timestamps as deletion indicator + +Revision ID: 830416cab29c +Revises: c285b0606094 +Create Date: 2026-07-11 06:12:14.423902 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "830416cab29c" +down_revision: Union[str, Sequence[str], None] = "c285b0606094" +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.add_column( + "beratung_einzelberatung", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + op.add_column( + "beratung_vorgang", sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("beratung_vorgang", "geloescht") + op.drop_column("beratung_einzelberatung", "geloescht") + # ### end Alembic commands ### diff --git a/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py b/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py new file mode 100644 index 0000000..e367e15 --- /dev/null +++ b/alembic/versions/85577ce9f2ab_timestamps_for_deletions_of_initial_.py @@ -0,0 +1,42 @@ +"""timestamps for deletions of initial recording + +Revision ID: 85577ce9f2ab +Revises: 830416cab29c +Create Date: 2026-07-15 17:06:55.947134 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +import wce_crm.db + +# revision identifiers, used by Alembic. +revision: str = "85577ce9f2ab" +down_revision: Union[str, Sequence[str], None] = "830416cab29c" +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.add_column( + "grunderfassung_personen", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + op.add_column( + "grunderfassung_unternehmen", + sa.Column("geloescht", wce_crm.db.UTCDateTime(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("grunderfassung_unternehmen", "geloescht") + op.drop_column("grunderfassung_personen", "geloescht") + # ### end Alembic commands ### 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/c285b0606094_updated_timestamps_nullability.py b/alembic/versions/c285b0606094_updated_timestamps_nullability.py new file mode 100644 index 0000000..438c227 --- /dev/null +++ b/alembic/versions/c285b0606094_updated_timestamps_nullability.py @@ -0,0 +1,48 @@ +"""updated timestamps nullability + +Revision ID: c285b0606094 +Revises: 2afa4e506448 +Create Date: 2026-07-10 09:22:12.340425 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c285b0606094" +down_revision: Union[str, Sequence[str], None] = "2afa4e506448" +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("beratung_einzelberatung") as batch_op: + batch_op.alter_column( + "aktualisiert", + existing_type=sa.DATETIME(), + nullable=False, + ) + + with op.batch_alter_table("beratung_vorgang") as batch_op: + batch_op.alter_column("aktualisiert", existing_type=sa.DATETIME(), nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("beratung_einzelberatung") as batch_op: + batch_op.alter_column( + "aktualisiert", + existing_type=sa.DATETIME(), + nullable=True, + ) + + with op.batch_alter_table("beratung_vorgang") as batch_op: + batch_op.alter_column("aktualisiert", existing_type=sa.DATETIME(), nullable=True) + # ### end Alembic commands ### diff --git a/alembic/versions/e290fe18132d_update_outstanding_table_changes.py b/alembic/versions/e290fe18132d_update_outstanding_table_changes.py new file mode 100644 index 0000000..ab4f5ea --- /dev/null +++ b/alembic/versions/e290fe18132d_update_outstanding_table_changes.py @@ -0,0 +1,38 @@ +"""update outstanding table changes + +Revision ID: e290fe18132d +Revises: 8000d15faf03 +Create Date: 2026-07-27 09:05:06.301043 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e290fe18132d" +down_revision: Union[str, Sequence[str], None] = "8000d15faf03" +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("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.add_column( + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.Text(), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("Personen_Unternehmen_Zuordnung", schema=None) as batch_op: + batch_op.drop_column("Partnersuche__kanal_aufmerksamkeit") + + # ### 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 new file mode 100644 index 0000000..df381e6 --- /dev/null +++ b/data_model/project_db_schema.aml @@ -0,0 +1,119 @@ +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 + un_id int -> Unternehmen(un_id) unique=uq_aktive_zuordnung | alias 'Partnersuche__un_suche' + an_id int -> Ext_Kontaktliste.Ansprechpartner(an_id) | alias 'Partnersuche__person_suche + Partnersuche__kontaktweg text nullable + 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 | 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)` + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} + geloescht "timestamp with time zone" nullable + Metadaten_wiedereintrittsdatum timestamp nullable + Metadaten_nutzer varchar(20) nullable + Grunderfassung_fallnummer text nullable + Grunderfassung_notiz text nullable + HoehereBildung text nullable + Kontaktperson__KP_adresse text nullable + Kontaktperson__KP_anrede_anschrift text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_festnetznummer text nullable + Kontaktperson__KP_funktion_beziehung text nullable + Kontaktperson__KP_mobilfunknummer text nullable + Kontaktperson__KP_name_partner text nullable + Kontaktperson__KP_titel text nullable + Kontaktperson__KP_vorname text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_email text nullable + Kontaktperson__KP_email text nullable + Projektrelevanz__relevanz text nullable + Projektrelevanz__foerderperiode text nullable + Schulbildung text nullable + Stammdaten__kanal_aufmerksamkeit text nullable + Stammdaten__PLZ text nullable + Stammdaten__anrede_anschrift text nullable + Stammdaten__anzahl_kinder__alter text nullable + Stammdaten__anzahl_kinder__anzahl int nullable + Stammdaten__aufenthaltsort text nullable + Stammdaten__bundesland text nullable + Stammdaten__land text nullable + Stammdaten__email text nullable + Stammdaten__familienstand text nullable + Stammdaten__festnetznummer text nullable + Stammdaten__geburtsdatum date nullable + Stammdaten__hausnummer text nullable + Stammdaten__herkunftsland text nullable + Stammdaten__mobilfunknummer text nullable + Stammdaten__name text nullable + Stammdaten__ort text nullable + Stammdaten__rueckkehrer bool nullable + Stammdaten__staatsangehoerigkeit text nullable + Stammdaten__strasse text nullable + Stammdaten__titel text nullable + Stammdaten__vorname text nullable + WeitereInfos__WI_arbeitsstatus text nullable + WeitereInfos__WI_aufenthaltstitel text nullable + WeitereInfos__WI_deutsch_sprache text nullable + WeitereInfos__WI_gueltigkeit_aufenthaltstitel text nullable + WeitereInfos__WI_meldung_institution text 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 + vorgang_id int pk {autoIncrement} + un_id int nullable -> Unternehmen(un_id) | Pauschalberatungen sind sowohl für Unternehmen als auch Personen zulässig + pers_id int nullable -> Grunderfassung_Personen(pers_id) | sowohl Pauschal- als auch Individualberatung bei Personenbindung möglich + titel text + beratungs_typ berat_typ(pauschal, individual) + 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 + + +Beratung_Einzelberatung | Änderungen müssen über einen DB-Trigger an Elterntabelle zurückgeschrieben werden + beratung_id int pk {autoIncrement} + vorgang_id int -> Beratung_Vorgang(vorgang_id) | Jede Beratung muss zu einem Beratungsvorgang gehören. + erstellt "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + aktualisiert "timestamp with time zone"=`datetime.datetime.now(datetime.UTC)` {on_update: `datetime.datetime.now(datetime.UTC)`} | gekoppelt mit Elterntabelle 'Beratung_Vorgang' über Trigger + nutzer_id int | externe Nutzerverwaltung + nutzer_name text | externe Nutzerverwaltung + zeitstempel "timestamp with time zone" | Nutzereingabe + ansprechpartner text + kommunikationsweg text + thema_crm_matrix text nullable | Verlinkung ins CRM, genaue Gestaltung noch unklar + anmerkungen text nullable + rueckmeldung txt nullable + geloescht "timestamp with time zone" nullable diff --git a/deployment/.env b/deployment/.env index e2b29a0..8efb520 100644 --- a/deployment/.env +++ b/deployment/.env @@ -1,5 +1,6 @@ DOPT_STOP_FOLDER_NAME=python -DOPT_DB_CRM=data/wce_crm.db -DOPT_DB_MAIN=data/wce_grunderfassung.db +DOPT_DB_CRM=data/db/wce_crm.db +DOPT_DB_MAIN=data/db/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/docs/Versionshistorie.md b/docs/Versionshistorie.md index d2bda5c..e9825af 100644 --- a/docs/Versionshistorie.md +++ b/docs/Versionshistorie.md @@ -1,5 +1,34 @@ # Versionshistorie (Changelog) +## 31.07.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260731) + +- Umsetzung der folgenden Anforderungen (IDs): + - 1000 + - 1002 + - 1003 + - 1004 + - 1005 + - 1006 + - 1007 + - 1008 + - 1009 + - 1011 + - 1012 + - 1013 + - 1014 +- Grunderfassung komplett überarbeitet: + - mehr Übersicht durch aufklappbare Bereiche + - Hinzufügen und Verwalten von Dokumenten + - weitere gemäß Anforderungsliste +- Unternehmensprofil: + - hinzugefügt + - Möglichkeit zur Anlage von Beratungsvorgängen +- Beratungsvorgänge: + - Anlegen über Startseite "Neu" und über Unternehmensprofil + - nachträgliche Verknüpfung mit Unternehmen/Personen (kontextabhängig) + - Hinzufügen/Verwalten mehrerer Sessions + - Platzhalter für Verlinkung in CRM-Matrix (mehrere Elemente sollen später ausgewählt werden) + ## 17.06.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260617) - Grunderfassung für Individualpersonen hinzugefügt diff --git a/pdm.lock b/pdm.lock index a9df463..195d484 100644 --- a/pdm.lock +++ b/pdm.lock @@ -3571,29 +3571,29 @@ files = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.21" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["lint"] files = [ - {file = "ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f"}, - {file = "ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e"}, - {file = "ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609"}, - {file = "ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07"}, - {file = "ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48"}, - {file = "ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5"}, - {file = "ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed"}, - {file = "ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188"}, - {file = "ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e"}, + {file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"}, + {file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"}, + {file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"}, + {file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"}, + {file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"}, + {file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"}, + {file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"}, + {file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"}, + {file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"}, ] [[package]] diff --git a/prototypes/dual_dict.py b/prototypes/dual_dict.py new file mode 100644 index 0000000..17fc7b3 --- /dev/null +++ b/prototypes/dual_dict.py @@ -0,0 +1,54 @@ +# %% +from __future__ import annotations + +import copy +from typing import Any + +from dopt_basics.datastructures import DualDict + +from wce_crm.data_models import FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + +# %% +dd: DualDict[str, str] = DualDict(peter="test") + + +# %% +def rename_db_data( + db_data: dict[str, Any], + mapping: DualDict, +) -> dict[str, Any]: + new_db_data = copy.deepcopy(db_data) + + for k, v in db_data.items(): + if k in mapping: + new_db_data[mapping[k]] = v + del new_db_data[k] + + if k in mapping.inverted: + new_db_data[mapping.inverted[k]] = v + del new_db_data[k] + + return new_db_data + + +form_data = { + "Metadaten_erstellung": "t1", + "Metadaten_aktualisierung": "t2", + "Metadaten_nutzer": "t3", +} +db_data = rename_db_data(form_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) +# %% +db_data + +# %% +cvt_form_data = rename_db_data(db_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) +cvt_form_data +# %% +dd["test"] = "peter" +# %% +dd["test"] +# %% +dd.inverted["peter"] +# %% +dd +# %% diff --git a/prototypes/tests.py b/prototypes/tests.py index 1b5102f..c7f38d5 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -8,43 +8,281 @@ import json import re from collections import defaultdict from collections.abc import Sequence -from pprint import pprint +from pprint import pformat, pprint from typing import Annotated, Any import babel import polars as pl -import sqlalchemy as sql +import sqlalchemy as sa from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator from PySide6.QtCore import QDate, Qt from wce_crm import constants, db from wce_crm.backend import backend +from wce_crm.types import EntityIds, MainPageEntry, RecordingType # %% -int("test") +# backend.page_consulting_linking_companies(None) +# backend.page_consulting_linking_persons(3) +ret = backend.main_page_get_entries() +pprint(ret) +# %% +with db.ENGINE.connect() as conn: + q_relevant_entries = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.aktualisiert, + db.t_zuordnung_personen_unternehmen.c.id.label("link_id"), + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ).subquery("relevant_entries") + + stmt = sa.select( + q_relevant_entries, + db.t_ext_crm_master_attach.c.ma_unternehmensname, + ).outerjoin( + db.t_ext_crm_master_attach, + q_relevant_entries.c.un_id == db.t_ext_crm_master_attach.c.ma_id, + ) + + ret = conn.execute(stmt) + + # results = list(ret.mappings()) + # print(f"Results are:\n{pformat(results)}") + + main_page_entries: list[MainPageEntry] = [] + for entry in ret.mappings(): + ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], + ) + recording_type = RecordingType.WITHOUT_COMPANY + if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + + first_name = entry["Stammdaten__vorname"] + last_name = entry["Stammdaten__name"] + location_city = entry["Stammdaten__ort"] + person_name = f"{first_name} {last_name}" + if location_city: + person_name += f" (Ort: {location_city})" + + company_name = entry["ma_unternehmensname"] + + display_name = person_name + if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + + main_page_entries.append( + MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, + ) + ) + main_page_entries.sort(key=lambda x: x.dt_updated, reverse=True) + pprint(main_page_entries) +# %% + + +# %% +entry = results[0] +ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], +) +recording_type = RecordingType.WITHOUT_COMPANY +if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + +first_name = entry["Stammdaten__vorname"] +last_name = entry["Stammdaten__name"] +location_city = entry["Stammdaten__ort"] +person_name = f"{first_name} {last_name}" +if location_city: + person_name += f" (Ort: {location_city})" + +company_name = entry["ma_unternehmensname"] + +display_name = person_name +if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + +main_page_entry = MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, +) + +# %% +pprint(main_page_entry) + + +# %% +# %% +@dc.dataclass(slots=True, kw_only=True) +class LinkingPerson: + pers_id: int + display_name: str + un_ids: set[int] + + +with db.ENGINE.connect() as conn: + # if un_id None: list all persons + # else: list just the persons associated with the company + + stmt = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ) + + # if un_id is not None: + # person_select = person_select.select_from( + # db.t_grunderfassung_personen.join( + # db.t_zuordnung_personen_unternehmen, + # db.t_grunderfassung_personen.c.pers_id + # == db.t_zuordnung_personen_unternehmen.c.pers_id, + # ) + # ).where(db.t_zuordnung_personen_unternehmen.c.un_id == un_id) + + ret = conn.execute(stmt) + + persons_map: dict[str, Any] = {} + for row in ret: + p_id = row.pers_id + + if p_id not in persons_map: + first_name = row.Stammdaten__vorname + last_name = row.Stammdaten__name + location_city = row.Stammdaten__ort + + person_name = f"{first_name} {last_name}" + if location_city: + person_name += f" (Ort: {location_city})" + + persons_map[p_id] = { + "pers_id": p_id, + "display_name": person_name, + "un_ids": set(), + } + + # Falls verknüpft, ID zur Menge hinzufügen + if row.un_id is not None: + persons_map[p_id]["un_ids"].add(row.un_id) + + person_names = [LinkingPerson(**data) for data in persons_map.values()] + + +# person_names = ret.mappings().all() +# if not person_names: +# print(f"No persons found for 'un_id': {un_id}") + +print(f"[Page Consulting -- Linking] Retrieved person names:\n{pformat(person_names)}") + +# %% +dic = { + "t1": "test1", + "t2": "test2", + "t3": "test3", + "t4": "test4", +} +filter_keys = frozenset(("t3", "t2")) + +dic_in, dic_out = backend.split_dict(dic, filter_keys) + +print(f"{dic_in=}") +print(f"{dic_out=}") + + +# %% +dic_update = { + "t5": "new", + "t6": "new_2", +} + +dic_updated = backend.dic_deep_update(dic, dic_update) +print(f"{dic=}") +print(f"{dic_update=}") +print(f"{dic_updated=}") + +# %% +raw_ent_ids = EntityIds() +db_ent_ids = EntityIds(un_id=3, an_id=2) + +print(f"{raw_ent_ids=}\n{db_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") +# %% +raw_ent_ids.update(db_ent_ids) +print(f"{raw_ent_ids=}") +db_ent_ids = EntityIds(un_id=None, an_id=2, link_id=1) +raw_ent_ids.update(db_ent_ids) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") +raw_ent_ids.update(EntityIds()) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.valid()=}") + +# %% +raw_ent_ids.update(EntityIds(un_id=None, pers_id=2)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty()=}") +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") +raw_ent_ids.update(EntityIds(un_id=None, an_id=2, link_id=3, pers_id=2)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}") +raw_ent_ids.update(EntityIds(un_id=2, pers_id=None, an_id=2, link_id=3)) +print(f"{raw_ent_ids=}") +print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}") +print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}") +print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}") +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)}") # %% -stmt = sql.select( - db.grunderfassung.c.erfassung_id, - db.grunderfassung.c.Partnersuche__un_suche, -) - -with engine.connect() as conn: - res = conn.execute(stmt) - - -for r in res: - print(r) -# %% # %% -backend.front_get_company_list() +backend._main_page_get_company_list() # type: ignore # %% @@ -218,12 +456,12 @@ class FlatBaseModel(BaseModel): return final_nested_data - def to_db(self) -> Dict[str, Any]: + def to_db(self) -> dict[str, Any]: """Ausgang für die DB: Flach, Listen sind JSON-Strings.""" nested = super().model_dump() return self.__flatten_dict(nested, serialize_lists=True) - def to_gui(self) -> Dict[str, Any]: + def to_gui(self) -> dict[str, Any]: """Ausgang für die GUI: Flach, aber Listen bleiben Python-Listen.""" nested = super().model_dump() return self.__flatten_dict(nested, serialize_lists=False) diff --git a/pyproject.toml b/pyproject.toml index f47c991..500fc03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wce-crm" -version = "0.1.1dev10" +version = "0.1.1dev11" description = "GUI for CRM of NAFKA project with WCE" authors = [ {name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, @@ -14,6 +14,14 @@ license = {text = "LicenseRef-Proprietary"} requires = ["pdm-backend"] build-backend = "pdm.backend" +[tool.pyright] +typeCheckingMode = "basic" +pythonVersion = "3.11" +reportUnusedImport = "none" +reportUnusedVariable = "warning" +reportUnusedClass = "warning" +reportUnusedFunction = "warning" +reportPrivateUsage = "error" [tool.ruff] line-length = 94 @@ -26,7 +34,7 @@ quote-style = "double" skip-magic-trailing-comma = false [tool.ruff.lint] -select = ["E", "F", "I"] +select = ["E", "F", "I", "SLF"] [tool.ruff.lint.isort] extra-standard-library = ["typing_extensions"] @@ -71,7 +79,7 @@ directory = "reports/coverage" [tool.bumpversion] -current_version = "0.1.1dev10" +current_version = "0.1.1dev11" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 55424fb..928a109 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -45,15 +45,10 @@ Write-Output "Deleted outstanding artifacts successfully" Write-Output "Create data folder..." $pattern = "dopt_nafka_wce-crm_v*" -$data_folder = Join-Path -Path $ENV_PATH -ChildPath 'data' -if (-not (Test-Path -Path $data_folder)){ - Write-Output "[PWSH] Data path not existing. Create..." - New-Item -Path $data_folder -ItemType Directory -} -else { - Write-Output "Data path already exists." -} +create_folder -base_path $ENV_PATH -folder_name 'data' -recreate +create_folder -base_path $ENV_PATH -folder_name 'data/db' +create_folder -base_path $ENV_PATH -folder_name 'data/files' Write-Output "Building package..." @@ -93,10 +88,10 @@ if ($? -eq $false){ } Write-Output "Successfully installed package" -# copy database file +# copy database files Write-Output "Copying database files..." $copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/db/wce_grunderfassung.db' -$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data' +$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/db' Copy-Item -Path $copy_file -Destination $dest_file -Force if ($? -eq $false){ Write-Output "[PWSH] Exiting script because there were errors while copying database 'Grunderfassung'" @@ -110,6 +105,17 @@ if ($? -eq $false){ } Write-Output "Copied database files successfully" +# copy content files +Write-Output "Copying content files (documents)..." +$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/files' +$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/' +Copy-Item -Path $copy_file -Destination $dest_file -Force -Recurse +if ($? -eq $false){ + Write-Output "[PWSH] Exiting script because there were errors while copying documents" + Exit +} +Write-Output "Copied content files (documents) successfully" + # copy alembic files Write-Output "Copying alembic files..." $copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic' 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/_remove.py b/src/wce_crm/_remove.py new file mode 100644 index 0000000..523c3e1 --- /dev/null +++ b/src/wce_crm/_remove.py @@ -0,0 +1,26 @@ +def _get_leafs(data): + if isinstance(data, dict): + for value in data.values(): + yield from _get_leafs(value) + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from _get_leafs(item) + else: + yield data + + +def _get_leaf_dicts(data): + if isinstance(data, dict): + has_inner_dicts = False + + for value in data.values(): + for inner_dict in _get_leaf_dicts(value): + has_inner_dicts = True + yield inner_dict + + if not has_inner_dicts: + yield data + + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from _get_leaf_dicts(item) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index fff4103..5c2c9c9 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -1,68 +1,59 @@ from __future__ import annotations -import dataclasses as dc +import copy import datetime -from typing import Any, TypeAlias, TypedDict, cast +from collections.abc import Container, Mapping +from pprint import pformat +from typing import TYPE_CHECKING, Any, Final, TypeVar, cast import polars as pl -import sqlalchemy as sql +import sqlalchemy as sa +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.logging import logger_back as logger +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, + FIELD_DB_SPLIT_METADATEN, + Beratungsgespraech_Einzelgespraech, + Beratungsgespraech_Vorgang, + InitRec, + Page_InitRec_Form_Data, +) +from wce_crm.logging import logger_backend as logger +from wce_crm.types import ( + CompanyInfo, + CompanyProfileConsultationEntry, + CompanyProfileConsultations, + ConsultingType, + ContactPersonInfo, + EntityIds, + EntityType, + LinkingConsultationsEntryCompany, + LinkingConsultationsEntryPerson, + MainPageEntry, + RecordingType, +) -InitRecId: TypeAlias = int +if TYPE_CHECKING: + from dopt_basics.datastructures import DualDict + from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId -class CompanyInfo(TypedDict): - ma_id: str - wce_id: str - ma_unternehmensname: str - ma_branche: str - ma_strasse: str - ma_hausnummer: str - ma_plz: str - ma_ort: str - ma_plz_postfach: str - ma_postfach: str - ma_website: str - ma_mail: str - ma_telefonnummer: str - ma_faxnummer: str - ma_ersteintrag_datum: str - ma_aktualisierung_datum: str - ma_aktualisierung_nutzer: str - ma_sollprozess: str - ma_auslaendische_mitarbeiter: str - ma_quelle_information: str - ma_bemerkung: str - ma_kontakt: str - ma_schlagworte: str - ma_archiviert: str +K = TypeVar("K") +V = TypeVar("V") - -class ContactPersonInfo(TypedDict): - an_id: str - ma_id: str - wce_id: str - st_id: str - an_sachgebiet: str - an_anrede: str - an_titel: str - an_nachname: str - an_vorname: str - an_position: str - an_mail: str - an_festnetz: str - an_mobil: str - an_faxnummer: str - an_hauptansprechpartner: str - an_anrede_anschrift: str - an_bemerkung: str - an_aktualisierung_datum: str - an_aktualisierung_nutzer: str - an_letztes_kontaktdatum: str - an_ersteintrag_datum: str - an_archiviert: str +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( @@ -99,8 +90,9 @@ def initrec_comp_search_choices() -> tuple[tuple[str, int], ...]: return tuple(zip(df["dedupl"], df["ma_id"])) -def initrec_comp_search_get_info( - ma_id: int, +# TODO change to direct SQL interaction +def external_company_get_info( + ma_id: ExtMaId, ) -> CompanyInfo: logger.debug("[Call backend] comp_search_get_info") df = db.DF_CRM_MASTER.filter(pl.col.ma_id == ma_id) @@ -112,7 +104,7 @@ def initrec_comp_search_get_info( def initrec_comp_contact_person_search_choices( - ma_id: int | None, + ma_id: ExtMaId | None, use_both_names: bool, ) -> tuple[tuple[str, int], ...]: # TODO no reload functionality @@ -140,7 +132,7 @@ def initrec_comp_contact_person_search_choices( def initrec_comp_contact_person_search_get_info( - an_id: int, + an_id: ExtAnId, ) -> ContactPersonInfo: logger.debug("[Call backend] contact_person_search_get_info") df = db.DF_CONTACT_PERSON.filter(pl.col.an_id == an_id) @@ -151,105 +143,705 @@ def initrec_comp_contact_person_search_get_info( return cast(ContactPersonInfo, df.row(0, named=True)) -def initrec_insert_initial_recording( - data: dict[str, Any], -) -> InitRecId: - logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung.insert() - with db.ENGINE.begin() as conn: - ret = conn.execute(stmt, data) +# // internals +def split_dict( + dic: dict[K, V], + split_keys: Container[K], +) -> tuple[dict[K, V], dict[K, V]]: + keys_in: dict[K, V] = {} + keys_out = copy.deepcopy(dic) - if ret.rowcount == 0: - raise IOError("Entry was not inserted correctly") + for check_key in dic: + if check_key in split_keys: + keys_in[check_key] = keys_out.pop(check_key) - prim_keys = ret.inserted_primary_key - assert prim_keys - - return prim_keys[0] + return keys_in, keys_out -def initrec_update_initial_recording( - id_: InitRecId, - data: dict[str, Any], -) -> None: - logger.debug("[Call backend] update_initial_recording") - stmt = db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_) - with db.ENGINE.begin() as conn: - conn.execute(stmt, data) +def dic_deep_update( + dic: dict[K, V], + data: Mapping[K, V], +) -> dict[K, V]: + dic_copy = copy.deepcopy(dic) + dic_copy.update(data) + + return dic_copy -def initrec_get_initial_recording( - id_: InitRecId, +def _rename_db_data( + db_data: dict[str, Any], + mapping: DualDict, ) -> dict[str, Any]: - logger.debug("[Call backend] get_initial_recording") - stmt = db.grunderfassung.select().where(db.grunderfassung.c.erfassung_id == id_) + new_db_data = copy.deepcopy(db_data) + + for k, v in db_data.items(): + if k in mapping: + new_db_data[mapping[k]] = v + del new_db_data[k] + + if k in mapping.inverted: + new_db_data[mapping.inverted[k]] = v + del new_db_data[k] + + return new_db_data + + +def page_initrec_to_db( + data: InitRec, +) -> InitRec: + with db.ENGINE.begin() as conn: + # person + dump_data = dic_deep_update(data.form_data.person, data.form_data.metadata) + dump_data = _rename_db_data(dump_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN) + dump_data["geloescht"] = data.geloescht + + if data.ids.pers_id is None: + logger.debug("[InitRec -- backend] Insert...") + + stmt = sa.insert(db.t_grunderfassung_personen).returning( + db.t_grunderfassung_personen + ) + stmt_compiled = str(stmt.compile(db.ENGINE)) + logger.debug( + "[InitRec -- backend] Data to insert:\nStatement: %s\n%s", + stmt_compiled, + pformat(dump_data), + ) + + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excactly one returned row" + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + + data.ids.pers_id = data_from_db["pers_id"] + + logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully") + + else: + logger.debug("[AutoForm -- backend] Update...") + stmt = ( + db.t_grunderfassung_personen.update() + .where(db.t_grunderfassung_personen.c.pers_id == data.ids.pers_id) + .returning(db.t_grunderfassung_personen) + ) + ret = conn.execute(stmt, dump_data) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + + # data.geloescht = data_from_db["geloescht"] + # data.form_data.person["Metadaten_aktualisierung"] = data_from_db[ + # "Metadaten_aktualisierung" + # ] + + logger.debug( + "[InitRec -- backend] Updated InitRec Person with ID %d successfully", + data.ids.pers_id, + ) + + data.geloescht = data_from_db["geloescht"] + del data_from_db["geloescht"] + in_filter, out_filter = split_dict(data_from_db, FIELD_DB_SPLIT_METADATEN) + 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 + assert data.ids.un_id, "company ID not set" + assert data.ids.an_id, "contact person (an) ID not set" + assert data.ids.pers_id, "person ID not set" + + stmt = ( + sqlite_insert(db.t_unternehmen) + .values(un_id=data.ids.un_id, geloescht=None) + .on_conflict_do_nothing(index_elements=["un_id"]) + ) + conn.execute(stmt) + + # additional information --> linking table + dump_data = _rename_db_data( + data.form_data.company, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) + logger.debug( + "[InitRec -- backend] Company - additional info: Dump Data = \n%s", + pformat(dump_data), + ) + if "un_id" not in dump_data: + # only case when updated + dump_data["un_id"] = data.ids.un_id + dump_data["an_id"] = data.ids.an_id + + stmt = sqlite_insert(db.t_zuordnung_personen_unternehmen).values( + **dump_data, pers_id=data.ids.pers_id + ) + + stmt = stmt.on_conflict_do_update( + index_elements=["un_id", "pers_id"], + index_where=sa.text("geloescht IS NULL"), + set_={ + "an_id": stmt.excluded.an_id, + "Partnersuche__kontaktweg": stmt.excluded.Partnersuche__kontaktweg, + "aktualisiert": datetime.datetime.now(datetime.UTC), + "geloescht": data.geloescht, + }, + ).returning(db.t_zuordnung_personen_unternehmen.c.id) + ret = conn.execute(stmt) + + from_db = ret.mappings().fetchall() + assert len(from_db) == 1, "expected excatly one returned row" + data_from_db = dict(from_db[0]) + data_from_db = _rename_db_data( + data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) + data.ids.link_id = data_from_db["id"] + + return data + + +def page_initrec_from_db( + ids: EntityIds, +) -> InitRec: + logger.debug("[InitRec -- backend] Call database loading routine...") + + recording_type = RecordingType.WITHOUT_COMPANY + if ids.link_id is not None or ids.valid(EntityType.COMPANY): + recording_type = RecordingType.WITH_COMPANY + + data_company_table: dict[str, Any] = {} + if recording_type is RecordingType.WITH_COMPANY: + logger.debug("[InitRec -- backend] With company. Link ID: %s", ids.link_id) + if ids.link_id: + stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg, + ).where( + db.t_zuordnung_personen_unternehmen.c.id == ids.link_id, + ) + else: + assert ids.un_id, "company ID not set" + assert ids.pers_id, "person ID not set" + stmt = sa.select( + db.t_zuordnung_personen_unternehmen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg, + ).where( + db.t_zuordnung_personen_unternehmen.c.pers_id == ids.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id == ids.un_id, + db.t_zuordnung_personen_unternehmen.c.geloescht.is_(None), + ) + + with db.ENGINE.connect() as conn: + ret = conn.execute(stmt) + + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_company_table = dict(from_db[0]) + + ids.pers_id = data_company_table.pop("pers_id") + ids.un_id = data_company_table["un_id"] + ids.an_id = data_company_table["an_id"] + data_company_table = _rename_db_data( + data_company_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN + ) + + # person + assert ids.pers_id, "person ID not set" + logger.debug("[InitRec -- backend] Person... Person ID: %s", ids.pers_id) + stmt = db.t_grunderfassung_personen.select().where( + db.t_grunderfassung_personen.c.pers_id == ids.pers_id + ) + with db.ENGINE.connect() as conn: ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found") + from_db = ret.mappings().all() + assert len(from_db) == 1, "not excactly one company initial recording obtained" + data_person_table = dict(from_db[0]) - row = ret.fetchone() - assert row, "row was not obtained" + data_person_table = _rename_db_data( + data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN + ) + geloescht = data_person_table["geloescht"] + del data_person_table["geloescht"] + in_filter, out_filter = split_dict(data_person_table, FIELD_DB_SPLIT_METADATEN) - return row._asdict() + form_data = Page_InitRec_Form_Data( + metadata=in_filter, + person=out_filter, + company=data_company_table, + ) + + if geloescht is not None: + logger.warning( + "[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, + ) -def initrec_delete_initial_recording( - id_: InitRecId, +@wrap_result(10) +def page_consulting_to_db( + consultation_data: Beratungsgespraech_Vorgang, +) -> Beratungsgespraech_Vorgang: + logger.debug("[Consulting Page] Call database saving routine...") + + with db.ENGINE.begin() as conn: + if consultation_data.vorgang_id is None: + # insert new "Vorgang" + insert_data_process = consultation_data.model_dump( + exclude={"vorgang_id", "beratungen"} + ) + logger.debug( + "[Consulting Page] Call insert 'Vorgang' with data:\n%s", + pformat(insert_data_process), + ) + + stmt = db.t_beratung_vorgang.insert() + ret = conn.execute(stmt, insert_data_process) + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + prim_keys = ret.inserted_primary_key + assert prim_keys + + consultation_data.vorgang_id = cast("ConsId", prim_keys[0]) + logger.debug("[Consulting Page] Inserted 'Vorgang' successfully") + + else: + logger.debug( + "[Consulting Page] VorgangID already set. ID: %d. Update...", + consultation_data.vorgang_id, + ) + update_data_process = consultation_data.model_dump(exclude={"beratungen"}) + stmt = db.t_beratung_vorgang.update().where( + db.t_beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id + ) + conn.execute(stmt, update_data_process) + + rows_for_db_insert: list[dict[str, Any]] = [] + rows_for_db_update: list[dict[str, Any]] = [] + + cons_sessions_inserted: list[Beratungsgespraech_Einzelgespraech] = [] + for cons_session in consultation_data.beratungen: + cons_session.vorgang_id = consultation_data.vorgang_id + if cons_session.beratung_id is None: + row_data = cons_session.model_dump(exclude={"beratung_id"}) + rows_for_db_insert.append(row_data) + cons_sessions_inserted.append(cons_session) + else: + row_data = cons_session.model_dump() + # new bind param to avoid name clashes + row_data["b_beratung_id"] = row_data["beratung_id"] + del row_data["beratung_id"] + rows_for_db_update.append(row_data) + + if rows_for_db_update: + # ... update + logger.debug( + "[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update) + ) + stmt = db.t_beratung_einzelberatung.update().where( + db.t_beratung_einzelberatung.c.beratung_id == sa.bindparam("b_beratung_id") + ) + conn.execute(stmt, rows_for_db_update) + + if rows_for_db_insert: + # ... insert + logger.debug( + "[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert) + ) + stmt = db.t_beratung_einzelberatung.insert().returning( + db.t_beratung_einzelberatung.c.beratung_id + ) + res = conn.execute(stmt, rows_for_db_insert) + new_cons_session_ids = cast(list[int], [row[0] for row in res.fetchall()]) + + assert len(cons_sessions_inserted) == len(new_cons_session_ids) + for cons_session, new_id in zip(cons_sessions_inserted, new_cons_session_ids): + cons_session.beratung_id = new_id + + return consultation_data + + +@wrap_result(10) +def page_consulting_delete( + cons_id: ConsId, ) -> None: - logger.debug("[Call backend] delete_initial_recording") - stmt = db.grunderfassung.delete().where(db.grunderfassung.c.erfassung_id == id_) + timestamp_deleted = datetime.datetime.now(datetime.UTC) + dump_data: dict[str, Any] = {"geloescht": timestamp_deleted} + with db.ENGINE.begin() as conn: + stmt = db.t_beratung_vorgang.update().where( + db.t_beratung_vorgang.c.vorgang_id == cons_id + ) + conn.execute(stmt, dump_data) + + stmt = db.t_beratung_einzelberatung.update().where( + db.t_beratung_einzelberatung.c.vorgang_id == cons_id, + db.t_beratung_einzelberatung.c.geloescht.is_(None), + ) + conn.execute(stmt, dump_data) + + +@wrap_result(11) +def page_consulting_from_db( + cons_id: ConsId, +) -> Beratungsgespraech_Vorgang: + # get "Vorgang" and all associated sessions to instantiate Pydantic data model + logger.debug("[Consulting Page] Call database reading routine...") + + with db.ENGINE.connect() as conn: + # get the consultation process + stmt = db.t_beratung_vorgang.select().where( + db.t_beratung_vorgang.c.vorgang_id == cons_id + ) ret = conn.execute(stmt) - if ret.rowcount == 0: - raise KeyError(f"Database ID {id_} not found for deletion") + results = ret.mappings().all() + + if not results: + raise KeyError(f"Database ID {cons_id} not found") + + assert len(results) == 1, "more than one consulting process obtained" + consultation_data_db = results[0] + consultation_data = Beratungsgespraech_Vorgang( + vorgang_id=consultation_data_db["vorgang_id"], + un_id=consultation_data_db["un_id"], + pers_id=consultation_data_db["pers_id"], + titel=consultation_data_db["titel"], + beratungs_typ=consultation_data_db["beratungs_typ"], + erstellt=consultation_data_db["erstellt"], + aktualisiert=consultation_data_db["aktualisiert"], + geloescht=consultation_data_db["geloescht"], + beratungen=[], + ) + + if consultation_data.geloescht is not None: + logger.info( + ( + "[Consulting Page] Obtained entry for ID=%d. This entry was marked " + "as deleted." + ), + cons_id, + ) + + # get all consultation sessions of this process, only the ones + # which are not marked as deleted + stmt = db.t_beratung_einzelberatung.select().where( + db.t_beratung_einzelberatung.c.vorgang_id == cons_id, + db.t_beratung_einzelberatung.c.geloescht.is_(None), + ) + ret = conn.execute(stmt) + cons_sessions = ret.mappings().all() + # empty results possible + if not cons_sessions: + logger.debug("[Consulting Page] No sessions, return directly...") + return consultation_data + + for session in cons_sessions: + cons_session_pydantic = Beratungsgespraech_Einzelgespraech( + beratung_id=session["beratung_id"], + vorgang_id=session["vorgang_id"], + nutzer_id=session["nutzer_id"], + nutzer_name=session["nutzer_name"], + zeitstempel=session["zeitstempel"], + ansprechpartner=session["ansprechpartner"], + kommunikationsweg=session["kommunikationsweg"], + thema_crm_matrix=session["thema_crm_matrix"], + anmerkungen=session["anmerkungen"], + rueckmeldung=session["rueckmeldung"], + erstellt=session["erstellt"], + aktualisiert=session["aktualisiert"], + ) + consultation_data.beratungen.append(cons_session_pydantic) + + logger.debug("[Consulting Page] Returning with sessions attached...") + + return consultation_data -@dc.dataclass(slots=True) -class FrontpageCompany: - erfassung_id: InitRecId - # ma_id: int - name: str - Metadaten_aktualisierung: datetime.datetime - is_company: bool +# // consulting page interaction +def page_consulting_linking_companies( + un_id: RecId | None, +) -> list[LinkingConsultationsEntryCompany]: + with db.ENGINE.connect() as conn: + q_relevant_companies = sa.select( + db.t_unternehmen.c.un_id, + ).where( + db.t_unternehmen.c.geloescht.is_(None), + ) + if un_id is not None: + q_relevant_companies = q_relevant_companies.where( + db.t_unternehmen.c.un_id == un_id + ) + + q_relevant_companies = q_relevant_companies.subquery("relevant_companies") + + stmt = sa.select( + q_relevant_companies.c.un_id, + db.t_ext_crm_master_attach.c.ma_unternehmensname, + ).select_from( + q_relevant_companies.join( + db.t_ext_crm_master_attach, + q_relevant_companies.c.un_id == db.t_ext_crm_master_attach.c.ma_id, + ) + ) + + ret = conn.execute(stmt) + company_names = ret.mappings().all() + if not company_names: + raise ValueError(f"No companies found for 'un_id': {un_id}") + + logger.debug("[Page Consulting -- Linking] Retrieved company names:\n%s", company_names) + + linking_entries: list[LinkingConsultationsEntryCompany] = [] + for entry in company_names: + linking_entries.append( + LinkingConsultationsEntryCompany( + un_id=entry["un_id"], + display_name=entry["ma_unternehmensname"], + ) + ) + + return linking_entries -def front_get_company_list() -> list[FrontpageCompany]: - logger.debug("[Call backend] get_company_list") - stmt = sql.select( - db.grunderfassung.c.erfassung_id, - db.grunderfassung.c.Partnersuche__un_suche, - db.grunderfassung.c.Metadaten_aktualisierung, - ).order_by(db.grunderfassung.c.Metadaten_aktualisierung.desc()) +def page_consulting_linking_persons() -> list[LinkingConsultationsEntryPerson]: + with db.ENGINE.connect() as conn: + stmt = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ) + + ret = conn.execute(stmt) + + persons_map: dict[str, LinkingConsultationsEntryPerson] = {} + for row in ret: + p_id = row.pers_id + + if p_id not in persons_map: + first_name = row.Stammdaten__vorname + last_name = row.Stammdaten__name + location_city = row.Stammdaten__ort + + display_name = f"{first_name} {last_name}" + if location_city: + display_name += f" (Ort: {location_city})" + + persons_map[p_id] = LinkingConsultationsEntryPerson( + pers_id=p_id, + un_ids=set(), + display_name=display_name, + ) + + if row.un_id is not None: + persons_map[p_id].un_ids.add(row.un_id) + + return list(persons_map.values()) + + +# TODO wrap result +def page_consulting_eastablish_link( + vorgang_id: ConsId, + un_id: RecId | None, + pers_id: RecId | None, +) -> None: + stmt = ( + sa.update(db.t_beratung_vorgang) + .where(db.t_beratung_vorgang.c.vorgang_id == vorgang_id) + .values(un_id=un_id, pers_id=pers_id) + ) + + with db.ENGINE.begin() as conn: + conn.execute(stmt) + + +# // company profile interaction +def companyprofile_page_get_consultations( + un_id: RecId, +) -> CompanyProfileConsultations: + logger.debug("[Call backend] _companyprofile_page_get_consultations") + stmt = sa.select( + db.t_beratung_vorgang.c.vorgang_id, + db.t_beratung_vorgang.c.aktualisiert, + db.t_beratung_vorgang.c.titel, + db.t_beratung_vorgang.c.beratungs_typ, + ).where( + db.t_beratung_vorgang.c.un_id == un_id, db.t_beratung_vorgang.c.geloescht.is_(None) + ) with db.ENGINE.connect() as conn: res = conn.execute(stmt) - front_page_companies: list[FrontpageCompany] = [] + cons_entries_pauschal: list[CompanyProfileConsultationEntry] = [] + cons_entries_individual: list[CompanyProfileConsultationEntry] = [] - for entry in res: - erfassung_id = entry[0] - ma_id = entry[1] - datetime_akt = cast(datetime.datetime, entry[2]) - datetime_akt = datetime_akt.astimezone() + for entry in res.mappings(): + cons_id = entry["vorgang_id"] + assert cons_id, "no VorgangID defined" - name: str = "PLATZHALTER INDIVIDUAL" - is_company: bool = False - if ma_id is not None: - comp_info = initrec_comp_search_get_info(ma_id) - name = comp_info["ma_unternehmensname"] - is_company = True + datetime_updated = cast(datetime.datetime, entry["aktualisiert"]) + datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST) + base_title = entry["titel"] + cons_type = ConsultingType(entry["beratungs_typ"]) - # front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt)) - front_page_companies.append( - FrontpageCompany( - erfassung_id=erfassung_id, - name=name, - Metadaten_aktualisierung=datetime_akt, - is_company=is_company, + con_entry = CompanyProfileConsultationEntry( + cons_id=cons_id, + title=base_title, + date_updated=datetime_updated, + cons_type=cons_type, + ) + + if cons_type is ConsultingType.PAUSCHAL: + cons_entries_pauschal.append(con_entry) + elif cons_type is ConsultingType.INDIVIDUAL: + cons_entries_individual.append(con_entry) + else: + raise TypeError(f"Unknown consulting type: {cons_type}") + + cons_entries_pauschal.sort(key=lambda x: x.date_updated, reverse=True) + cons_entries_individual.sort(key=lambda x: x.date_updated, reverse=True) + + return CompanyProfileConsultations( + pauschal=cons_entries_pauschal, + individual=cons_entries_individual, + ) + + +# // initial recording interaction +# TODO wrap result, add to corresponding component +def initrec_link_person_company( + un_id: RecId, + pers_id: RecId, + unlink: bool, +) -> None: + if unlink: + deleted_ts = datetime.datetime.now(tz=datetime.UTC) + stmt = ( + sa.update(db.t_zuordnung_personen_unternehmen) + .where( + db.t_zuordnung_personen_unternehmen.c.un_id == un_id, + db.t_zuordnung_personen_unternehmen.c.pers_id == pers_id, + db.t_zuordnung_personen_unternehmen.c.gueltig_bis.is_(None), + ) + .values(gueltig_bis=deleted_ts) + ) + else: + stmt = ( + sqlite_insert(db.t_zuordnung_personen_unternehmen) + .values(un_id=un_id, pers_id=pers_id) + .on_conflict_do_nothing( + index_elements=["un_id", "pers_id"], + index_where=sa.text("gueltig_bis IS NULL"), ) ) - return front_page_companies + with db.ENGINE.begin() as conn: + conn.execute(stmt) + + +# // main page interaction +def main_page_get_entries() -> list[MainPageEntry]: + with db.ENGINE.connect() as conn: + q_relevant_entries = ( + sa.select( + db.t_grunderfassung_personen.c.pers_id, + db.t_grunderfassung_personen.c.aktualisiert, + db.t_zuordnung_personen_unternehmen.c.id.label("link_id"), + db.t_zuordnung_personen_unternehmen.c.un_id, + db.t_zuordnung_personen_unternehmen.c.an_id, + db.t_grunderfassung_personen.c.Stammdaten__name, + db.t_grunderfassung_personen.c.Stammdaten__vorname, + db.t_grunderfassung_personen.c.Stammdaten__ort, + ) + .outerjoin( + db.t_zuordnung_personen_unternehmen, + db.t_grunderfassung_personen.c.pers_id + == db.t_zuordnung_personen_unternehmen.c.pers_id, + ) + .where(db.t_grunderfassung_personen.c.geloescht.is_(None)) + .order_by(db.t_grunderfassung_personen.c.Stammdaten__name) + ).subquery("relevant_entries") + + stmt = sa.select( + q_relevant_entries, + db.t_ext_crm_master_attach.c.ma_unternehmensname, + ).outerjoin( + db.t_ext_crm_master_attach, + q_relevant_entries.c.un_id == db.t_ext_crm_master_attach.c.ma_id, + ) + + ret = conn.execute(stmt) + + main_page_entries: list[MainPageEntry] = [] + for entry in ret.mappings(): + ids = EntityIds( + pers_id=entry["pers_id"], + link_id=entry["link_id"], + un_id=entry["un_id"], + an_id=entry["an_id"], + ) + recording_type = RecordingType.WITHOUT_COMPANY + if ids.un_id or ids.link_id: + recording_type = RecordingType.WITH_COMPANY + + first_name = entry["Stammdaten__vorname"] + last_name = entry["Stammdaten__name"] + location_city = entry["Stammdaten__ort"] + person_name = f"{first_name} {last_name}" + if location_city: + person_name += f" (Ort: {location_city})" + + company_name = entry["ma_unternehmensname"] + + display_name = person_name + if recording_type is RecordingType.WITH_COMPANY: + display_name = company_name + + main_page_entries.append( + MainPageEntry( + ids=ids, + display_name=display_name, + dt_updated=entry["aktualisiert"], + recording_type=recording_type, + ) + ) + main_page_entries.sort(key=lambda x: x.dt_updated, reverse=True) + + return main_page_entries 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 70ac77d..cf7e637 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import zoneinfo from pathlib import Path from typing import Final @@ -18,10 +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/custom_widget_registry.py b/src/wce_crm/custom_widget_registry.py index f1c6d8a..1d4cb09 100644 --- a/src/wce_crm/custom_widget_registry.py +++ b/src/wce_crm/custom_widget_registry.py @@ -4,6 +4,6 @@ from typing import Final CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset( [ - "grunderfassung_suche", + "grunderfassung_unternehmen_suche", ] ) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 9575ea7..f95ca2f 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -1,9 +1,14 @@ from __future__ import annotations +import copy +import dataclasses as dc import datetime import json -from typing import Annotated, Any, Final +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 from pydantic import ( AwareDatetime, BaseModel, @@ -13,8 +18,55 @@ from pydantic import ( field_validator, model_validator, ) +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)] +M = TypeVar("M", bound="Module") +S = TypeVar("S", bound="PageState") + +# // validation translation +GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = { + "missing": "Dieses Feld ist ein Pflichtfeld.", + "int_parsing": "Bitte eine gültige Ganzzahl eingeben.", + "float_parsing": "Bitte eine gültige Dezimalzahl eingeben.", + "string_too_short": "Der Text ist zu kurz. Er muss mindestens {min_length} Zeichen lang sein.", + "string_too_long": "Der Text ist zu lang. Er darf maximal {max_length} Zeichen lang sein.", + "greater_than": "Der Wert muss größer als {gt} sein.", +} + + +def translate_pydantic_errors( + errors: list[ErrorDetails], +) -> list[ErrorDetails]: + translated_errors: list[ErrorDetails] = [] + + for error in errors: + err_type = error.get("type", None) + + if err_type is None or err_type not in GERMAN_ERROR_MESSAGES: + error["msg"] = "[fehlende Übersetzung, nur Englisch]: " + error["msg"] + translated_errors.append(error) + continue + + msg_template = GERMAN_ERROR_MESSAGES[err_type] + ctx = error.get("ctx", {}) + try: + error["msg"] = msg_template.format(**ctx) + except KeyError: + error["msg"] = msg_template + + translated_errors.append(error) + + return translated_errors def _parse_json(value: Any) -> str: @@ -26,9 +78,278 @@ def _parse_json(value: Any) -> str: raise TypeError -COLUMN_SEP: Final[str] = "__" +# // interfaces +class WrapperModule(Protocol): + def _sync_state_to_GUI(self, *args, **kwargs) -> None: + """build states for all child modules and use their `load_state` method + to initialise them with the new data + """ + ... + + def _sync_GUI_to_state(self, *args, **kwargs) -> None: + """call `get_state` method on all child modules and assign respective + properties to state of parent module + """ + ... + + def load_state(self, new_state: Any) -> None: ... + + def get_state(self) -> Any: + """should always be a simple call to `_sync_GUI_to_state` and then returning + the module's state + + Returns + ------- + Any + state of the respective module + """ + ... + + def lock(self) -> None: ... + + def unlock(self) -> None: ... +class Module(WrapperModule, Protocol): + def validate(self) -> list[str]: + """should be a list of field names which could not be validated successfully, + propagates through: parent components must call this on all child modules and extend + their own error list with the return value + + Returns + ------- + list[str] + list of error fields (form field label) + """ + ... + + def save_data(self) -> None: ... + + +class InitRecFormToDb(Protocol): + def __call__( + self, + auto_form_data: InitRec, + ) -> InitRec: ... + + +class InitRecFormFromDb(Protocol): + def __call__( + self, + ids: EntityIds, + ) -> InitRec: ... + + +# // GUI states and types +def set_page_state( + target: S, + source: S, + ignore_attr: set[str] | None = None, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and target must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + if ignore_attr is None: + ignore_attr = set() + ignore_attr.add("child_modules") + + for field in dc.fields(source): + if field.name in ignore_attr: + continue + + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) + + +@dc.dataclass(slots=True) +class Session: + user_id: UserId + user_name: str + + +@dc.dataclass(slots=True) +class PageState(Generic[M]): + child_modules: list[M] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_MainPage_State(PageState[Module]): + session: Session + row_count: int = 0 + + +@dc.dataclass(slots=True, kw_only=True) +class Page_NewEntry_State(PageState[Module]): + session: Session + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_Form_Data: + metadata: dict[str, Any] + person: dict[str, Any] + company: dict[str, Any] + + +def _create_empty_initrec_form_data() -> Page_InitRec_Form_Data: + return Page_InitRec_Form_Data( + metadata={}, + person={}, + company={}, + ) + + +# !! there must be exactly one InitRecForm for each recording type +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_State(PageState["InitRecForm"]): + session: Session + ids: EntityIds = dc.field(default_factory=EntityIds) + recording_type: RecordingType + # un_id: RecId | None = None + locked: bool + geloescht: bool = False + form_data: Page_InitRec_Form_Data = dc.field( + default_factory=_create_empty_initrec_form_data + ) + 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( + ( + EntityType.METADATA, + EntityType.COMPANY, + EntityType.PERSON, + ) + ) + + for ent_type in relevant_entities: + initrec_form_state = Page_InitRec_Form_State( + session=self.session, + cfg=INITREC_CONFIGS[ent_type], + ids=self.ids, + ent_type=ent_type, + locked=self.locked, + geloescht=self.geloescht, + ) + self.initrec_form_states[ent_type] = initrec_form_state + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_Form_Config: + pydantic_model: type[FlatBaseModel] + form_fields: Sequence[FormField] + ignored_keys: Iterable[str] = tuple() + add_buttons: bool = False + id_mapping: dict[str, str] + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRec_Form_State(PageState[Module]): + session: Session + cfg: Page_InitRec_Form_Config + ids: EntityIds + ent_type: EntityType + # rec_id: RecId | None = None + form_data: dict[str, Any] = dc.field(default_factory=dict) + locked: bool + 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 + vorgang_id: ConsId | None + un_id: RecId | None = None + pers_id: RecId | None = None + titel: str = "TEST-TITEL" # add title later to GUI or build it in the background + beratungs_typ: ConsultingType + cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( + default_factory=list + ) + geloescht: bool = False + locked: bool + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Linking_State(PageState[Module]): + session: Session + un_id: RecId | None = None + pers_id: RecId | None = None + beratungs_typ: ConsultingType + filter_un_ids: set[RecId] | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_ConsultingSession_State(PageState[Module]): + session: Session + nutzer_id: UserId # must be set with the session (always known) + nutzer_name: str # must be set with the session (always known) + beratung_id: int | None # known and not known entries + zeitstempel: datetime.datetime | None = None + ansprechpartner: str | None = None + kommunikationsweg: str | None = None + thema_crm_matrix: str | None = ( + None # TODO should be linked to something, target unclear (need specification by customer) + ) + anmerkungen: str | None = None + rueckmeldung: str | None = None + geloescht: bool = False + locked: bool + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): + session: Session + row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) + locked: bool + + +@dc.dataclass(slots=True, kw_only=True) +class Page_CompanyProfile_State(PageState[Module]): + session: Session + un_id: RecId | None = None + + +# // Pydantic models class FlatBaseModel(BaseModel): """ Optimised Pydantic base class, which parses JSON strings and column @@ -83,7 +404,7 @@ class FlatBaseModel(BaseModel): @model_validator(mode="before") @classmethod - def __unflatten_input( + def _unflatten_input( cls, data: Any, ) -> Any: # type: ignore @@ -141,8 +462,58 @@ class FlatBaseModel(BaseModel): return dict(items) -class Grunderfassung(FlatBaseModel): +# ** 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 +FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN: Final[DualDict[str, str]] = DualDict( + Metadaten_erstellung="erstellt", + Metadaten_aktualisierung="aktualisiert", +) + +FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN: Final[DualDict[str, str]] = DualDict( + Partnersuche__un_suche="un_id", + Partnersuche__person_suche="an_id", +) + +FIELD_DB_SPLIT_METADATEN: Final[frozenset[str]] = frozenset( + ( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + "Metadaten_nutzer", + "Metadaten_wiedereintrittsdatum", + "Grunderfassung_fallnummer", + "Grunderfassung_notiz", + ) +) + + +class Grunderfassung_Metadaten(FlatBaseModel): + # displayed and handled separately, on database level included in 'Person' Metadaten_erstellung: AwareDatetime | None = None Metadaten_aktualisierung: AwareDatetime | None = None # see above Metadaten_nutzer: str | None @@ -150,7 +521,24 @@ class Grunderfassung(FlatBaseModel): Grunderfassung_fallnummer: str Grunderfassung_notiz: str | None - Partnersuche: Grunderfassung_PartnerSuche | None = None + +class Grunderfassung_Unternehmen(FlatBaseModel): + # default in SQLAlchemy with lambda and timezone-aware datetime + # Metadaten_erstellung: AwareDatetime | None = None + # Metadaten_aktualisierung: AwareDatetime | None = None # see above + # Metadaten_nutzer: str | None + # Grunderfassung_fallnummer: str + # Grunderfassung_notiz: str | None + + Partnersuche: Grunderfassung_PartnerSuche + + +class Grunderfassung_Personen(FlatBaseModel): + # default in SQLAlchemy with lambda and timezone-aware datetime + # Metadaten_erstellung: AwareDatetime | None = None + # Metadaten_aktualisierung: AwareDatetime | None = None # see above + # Metadaten_nutzer: str | None + Projektrelevanz: Grunderfassung_Projektrelevanz Kontaktperson: Grunderfassung_Kontaktperson Stammdaten: Grunderfassung_Stammdaten @@ -166,7 +554,7 @@ class Grunderfassung_PartnerSuche(BaseModel): un_suche: int | None person_suche: int | None - kanal_aufmerksamkeit: str | None + kontaktweg: str | None class Grunderfassung_Projektrelevanz(BaseModel): @@ -194,6 +582,7 @@ class Grunderfassung_Kontaktperson(BaseModel): class Grunderfassung_Stammdaten(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + kanal_aufmerksamkeit: str | None titel: str | None anrede_anschrift: str name: str @@ -295,3 +684,79 @@ class Grunderfassung_Sprachen(BaseModel): SP_nachweis: str | None SP_art_nachweis: str | None = None SP_datum_nachweis: datetime.date | None = None + + +# TODO placeholer docuemnt + + +# ** Consulting +class Beratungsgespraech_Vorgang(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + vorgang_id: int | None + un_id: int | None + pers_id: int | None + titel: str + beratungs_typ: ConsultingType + + # 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 + + beratungen: list[Beratungsgespraech_Einzelgespraech] + + +class Beratungsgespraech_Einzelgespraech(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + vorgang_id: int | None + beratung_id: int | None + nutzer_id: int + nutzer_name: str + zeitstempel: AwareDatetime + ansprechpartner: str + kommunikationsweg: str + thema_crm_matrix: str | None + anmerkungen: str | None + rueckmeldung: str | None + + # 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 + + +INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = { + EntityType.METADATA: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Metadaten, + ignored_keys=( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + ), + form_fields=INITREC_METADATEN, + add_buttons=False, + id_mapping={}, + ), + EntityType.COMPANY: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Unternehmen, + ignored_keys=tuple(), + form_fields=INITREC_COMP, + add_buttons=False, + id_mapping={ + "Partnersuche__un_suche": "un_id", + "Partnersuche__person_suche": "an_id", + }, + ), + EntityType.PERSON: Page_InitRec_Form_Config( + pydantic_model=Grunderfassung_Personen, + ignored_keys=tuple(), + # ignored_keys=( + # "Metadaten_erstellung", + # "Metadaten_aktualisierung", + # ), + form_fields=INITREC_PERSON, + add_buttons=False, + id_mapping={}, + ), +} diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 2c2a2a2..fb3f03d 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -6,8 +6,8 @@ import re from pathlib import Path import polars as pl -import sqlalchemy as sql -from sqlalchemy import Column, String, Table, TypeDecorator +import sqlalchemy as sa +from sqlalchemy import Column, Index, String, Table, TypeDecorator, event, sql from wce_crm import constants from wce_crm import types as t @@ -37,7 +37,7 @@ class SafeDateTime(TypeDecorator): class UTCDateTime(TypeDecorator): """Safely coerces naive datetimes from SQLite into timezone-aware UTC.""" - impl = sql.DateTime + impl = sa.DateTime cache_ok = True def process_bind_param(self, value, dialect): @@ -45,128 +45,70 @@ 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 = sql.MetaData() -MD_MAIN = sql.MetaData() -ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") +MD_CRM = sa.MetaData() +MD_MAIN = sa.MetaData() +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}, +) -# ---------- OLD "Kontaktliste" ---------- - -# md_kontaktliste = sql.MetaData() - -# ext_kl_unternehmen: sql.Table = Table( -# "Unternehmen", -# md_kontaktliste, -# Column("u_id", sql.Integer, nullable=False, unique=True), -# Column("u_zeitstempel_eintrag", sql.DateTime, nullable=False), -# Column("u_rechtsform", sql.Text, nullable=False), -# Column("u_firmenname", sql.Text, nullable=False), -# Column("u_strasse", sql.Text, nullable=False), -# Column("u_hausnummer", sql.Text, nullable=False), -# Column("u_adresszusatz", sql.Text, nullable=True), -# Column("u_plz", sql.Text, nullable=False), -# Column("u_ort", sql.Text, nullable=False), -# Column("u_postfach", sql.Text, nullable=True), -# Column("u_website", sql.Text, nullable=True), -# Column("u_anrede", sql.Text, nullable=False), -# Column("u_titel", sql.Text, nullable=True), -# Column("u_vorname", sql.Text, nullable=False), -# Column("u_nachname", sql.Text, nullable=False), -# Column("u_funktion", sql.Text, nullable=False), -# Column("u_mail", sql.Text, nullable=False), -# Column("u_telefon", sql.Text, nullable=False), -# Column("u_plz_postfach", sql.Text, nullable=True), -# Column("u_einwilligung_inhaber", sql.Boolean, nullable=True), -# Column("u_einwilligung_ansprechpartner", sql.Boolean, nullable=True), -# Column("u_aktiv", sql.Boolean, nullable=False, default=1), -# ) - -# ext_kl_unternehmen_schema: t.PolarsSchema = { -# "u_id": pl.UInt64, -# "u_zeitstempel_eintrag": pl.Datetime, -# "u_rechtsform": pl.String, -# "u_firmenname": pl.String, -# "u_strasse": pl.String, -# "u_hausnummer": pl.String, -# "u_adresszusatz": pl.String, -# "u_plz": pl.String, -# "u_ort": pl.String, -# "u_postfach": pl.String, -# "u_website": pl.String, -# "u_anrede": pl.String, -# "u_titel": pl.String, -# "u_vorname": pl.String, -# "u_nachname": pl.String, -# "u_funktion": pl.String, -# "u_mail": pl.String, -# "u_telefon": pl.String, -# "u_plz_postfach": pl.String, -# "u_einwilligung_inhaber": pl.Boolean, -# "u_einwilligung_ansprechpartner": pl.Boolean, -# "u_aktiv": pl.Boolean, -# } +KONTAKTLISTE_URI = constants.Config.DB_PATH_KONTAKTLISTE.as_uri() + "?mode=ro" -# def get_ext_kontaktliste( -# db_path: Path | None, -# ) -> pl.DataFrame: -# if db_path is None: -# ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None) -# if ENV_PTH is None: -# raise ValueError("No database path provided or found as ENV var.") -# db_path = Path(ENV_PTH) - -# if not db_path.exists(): -# raise FileNotFoundError(f"Database not found under >{db_path}<") - -# engine = sql.create_engine(f"sqlite:///{db_path}") -# stmt = sql.select(ext_kl_unternehmen) -# return pl.read_database(stmt, engine, schema_overrides=ext_kl_unternehmen_schema) +@event.listens_for(ENGINE, "connect") +def _db_conn_event(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + # 'kontaktliste' is the alias for the second DB + cursor.execute("ATTACH DATABASE ? AS kontaktliste", (KONTAKTLISTE_URI,)) + # enable foreign keys + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() -# df_kontaktliste = get_ext_kontaktliste(None) - -# ---------------------------------------------------- - -ext_crm_master: sql.Table = Table( +ext_crm_master: Table = Table( "Master", MD_CRM, - Column("ma_id", sql.Integer, nullable=False, unique=True), - Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), - Column("ma_unternehmensname", sql.Text, nullable=True), - Column("ma_branche", sql.Text, nullable=True), - Column("ma_strasse", sql.Text, nullable=True), - Column("ma_hausnummer", sql.Text, nullable=True), - Column("ma_plz", sql.Text, nullable=True), - Column("ma_ort", sql.Text, nullable=True), - Column("ma_plz_postfach", sql.Text, nullable=True), - Column("ma_postfach", sql.Text, nullable=True), - Column("ma_website", sql.Text, nullable=True), - Column("ma_mail", sql.Text, nullable=True), - Column("ma_telefonnummer", sql.Text, nullable=True), - Column("ma_faxnummer", sql.Text, nullable=True), + Column("ma_id", sa.Integer, nullable=False, unique=True), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sa.Text, nullable=True), + Column("ma_branche", sa.Text, nullable=True), + Column("ma_strasse", sa.Text, nullable=True), + Column("ma_hausnummer", sa.Text, nullable=True), + Column("ma_plz", sa.Text, nullable=True), + Column("ma_ort", sa.Text, nullable=True), + Column("ma_plz_postfach", sa.Text, nullable=True), + Column("ma_postfach", sa.Text, nullable=True), + Column("ma_website", sa.Text, nullable=True), + Column("ma_mail", sa.Text, nullable=True), + Column("ma_telefonnummer", sa.Text, nullable=True), + Column("ma_faxnummer", sa.Text, nullable=True), Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), - Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), - Column("ma_sollprozess", sql.Text, nullable=True), - Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), - Column("ma_quelle_information", sql.Text, nullable=True), - Column("ma_bemerkung", sql.Text, nullable=True), - Column("ma_kontakt", sql.Boolean, nullable=True), - Column("ma_schlagworte", sql.Text, nullable=True), - Column("ma_archiviert", sql.Boolean, nullable=True, default=False), + Column("ma_aktualisierung_nutzer", sa.Text, nullable=True), + Column("ma_sollprozess", sa.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True), + Column("ma_quelle_information", sa.Text, nullable=True), + Column("ma_bemerkung", sa.Text, nullable=True), + Column("ma_kontakt", sa.Boolean, nullable=True), + Column("ma_schlagworte", sa.Text, nullable=True), + Column("ma_archiviert", sa.Boolean, nullable=True, default=False), ) @@ -210,26 +152,26 @@ def get_ext_crm_master( if not db_path.exists(): raise FileNotFoundError(f"Database not found under >{db_path}<") - engine = sql.create_engine(f"sqlite:///{db_path}") - stmt = sql.select(ext_crm_master) + engine = sa.create_engine(f"sqlite:///{db_path}") + stmt = sa.select(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: sql.Table = Table( +ext_crm_nutzer: Table = Table( "Nutzer", MD_CRM, - Column("wce_id", sql.Integer, nullable=False, unique=True), - Column("wce_name", sql.Text, nullable=True), - Column("wce_vorname", sql.Text, nullable=True), - Column("wce_kuerzel", sql.Text, nullable=True), - Column("wce_passwort", sql.Text, nullable=True), - Column("wce_angelegt_am", sql.DateTime, nullable=True), - Column("wce_rolle", sql.Text, nullable=True), - Column("wce_angelegt_von", sql.Text, nullable=True), - Column("wce_aktiv", sql.Boolean, nullable=True), - Column("wce_letzter_login", sql.DateTime, nullable=True), + Column("wce_id", sa.Integer, nullable=False, unique=True), + Column("wce_name", sa.Text, nullable=True), + Column("wce_vorname", sa.Text, nullable=True), + Column("wce_kuerzel", sa.Text, nullable=True), + Column("wce_passwort", sa.Text, nullable=True), + Column("wce_angelegt_am", sa.DateTime, nullable=True), + Column("wce_rolle", sa.Text, nullable=True), + Column("wce_angelegt_von", sa.Text, nullable=True), + Column("wce_aktiv", sa.Boolean, nullable=True), + Column("wce_letzter_login", sa.DateTime, nullable=True), ) ext_crm_nutzer_schema: t.PolarsSchema = { @@ -245,31 +187,31 @@ ext_crm_nutzer_schema: t.PolarsSchema = { "wce_letzter_login": pl.Datetime, } -ext_crm_contact_person: sql.Table = Table( +ext_crm_contact_person: Table = Table( "Ansprechpartner", MD_CRM, - Column("an_id", sql.Integer, nullable=False, unique=True), - Column("ma_id", sql.ForeignKey("Master.ma_id")), - Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), - Column("st_id", sql.Integer, nullable=False), - Column("an_sachgebiet", sql.Text, nullable=True), - Column("an_anrede", sql.Text, nullable=True), - Column("an_titel", sql.Text, nullable=True), - Column("an_nachname", sql.Text, nullable=True), - Column("an_vorname", sql.Text, nullable=True), - Column("an_position", sql.Text, nullable=True), - Column("an_mail", sql.Text, nullable=True), - Column("an_festnetz", sql.Text, nullable=True), - Column("an_mobil", sql.Text, nullable=True), - Column("an_faxnummer", sql.Text, nullable=True), - Column("an_hauptansprechpartner", sql.Text, nullable=True), - Column("an_anrede_anschrift", sql.Text, nullable=True), - Column("an_bemerkung", sql.Text, nullable=True), + Column("an_id", sa.Integer, nullable=False, unique=True), + Column("ma_id", sa.ForeignKey("Master.ma_id")), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("st_id", sa.Integer, nullable=False), + Column("an_sachgebiet", sa.Text, nullable=True), + Column("an_anrede", sa.Text, nullable=True), + Column("an_titel", sa.Text, nullable=True), + Column("an_nachname", sa.Text, nullable=True), + Column("an_vorname", sa.Text, nullable=True), + Column("an_position", sa.Text, nullable=True), + Column("an_mail", sa.Text, nullable=True), + Column("an_festnetz", sa.Text, nullable=True), + Column("an_mobil", sa.Text, nullable=True), + Column("an_faxnummer", sa.Text, nullable=True), + Column("an_hauptansprechpartner", sa.Text, nullable=True), + Column("an_anrede_anschrift", sa.Text, nullable=True), + Column("an_bemerkung", sa.Text, nullable=True), Column("an_aktualisierung_datum", SafeDateTime, nullable=True), - Column("an_aktualisierung_nutzer", sql.Text, nullable=True), + Column("an_aktualisierung_nutzer", sa.Text, nullable=True), Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True), Column("an_ersteintrag_datum", SafeDateTime, nullable=True), - Column("an_archiviert", sql.Boolean, nullable=True, default=0), + Column("an_archiviert", sa.Boolean, nullable=True, default=0), ) @@ -311,8 +253,8 @@ def get_ext_crm_contact_person( if not db_path.exists(): raise FileNotFoundError(f"Database not found under >{db_path}<") - engine = sql.create_engine(f"sqlite:///{db_path}") - stmt = sql.select(ext_crm_contact_person) + engine = sa.create_engine(f"sqlite:///{db_path}") + stmt = sa.select(ext_crm_contact_person) df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema) df = df.with_columns( pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ") @@ -322,79 +264,348 @@ 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) -grunderfassung: sql.Table = Table( - "grunderfassung", +t_ext_crm_master_attach: Table = Table( + "Master", + MD_CRM, + Column("ma_id", sa.Integer, nullable=False, unique=True), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sa.Text, nullable=True), + Column("ma_branche", sa.Text, nullable=True), + Column("ma_strasse", sa.Text, nullable=True), + Column("ma_hausnummer", sa.Text, nullable=True), + Column("ma_plz", sa.Text, nullable=True), + Column("ma_ort", sa.Text, nullable=True), + Column("ma_plz_postfach", sa.Text, nullable=True), + Column("ma_postfach", sa.Text, nullable=True), + Column("ma_website", sa.Text, nullable=True), + Column("ma_mail", sa.Text, nullable=True), + Column("ma_telefonnummer", sa.Text, nullable=True), + Column("ma_faxnummer", sa.Text, nullable=True), + Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_nutzer", sa.Text, nullable=True), + Column("ma_sollprozess", sa.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True), + Column("ma_quelle_information", sa.Text, nullable=True), + Column("ma_bemerkung", sa.Text, nullable=True), + Column("ma_kontakt", sa.Boolean, nullable=True), + Column("ma_schlagworte", sa.Text, nullable=True), + Column("ma_archiviert", sa.Boolean, nullable=True, default=False), + schema="kontaktliste", +) + +t_ext_crm_contact_person_attach: Table = Table( + "Ansprechpartner", + MD_CRM, + Column("an_id", sa.Integer, nullable=False, unique=True), + Column("ma_id", sa.ForeignKey("Master.ma_id")), + Column("wce_id", sa.ForeignKey("Nutzer.wce_id")), + Column("st_id", sa.Integer, nullable=False), + Column("an_sachgebiet", sa.Text, nullable=True), + Column("an_anrede", sa.Text, nullable=True), + Column("an_titel", sa.Text, nullable=True), + Column("an_nachname", sa.Text, nullable=True), + Column("an_vorname", sa.Text, nullable=True), + Column("an_position", sa.Text, nullable=True), + Column("an_mail", sa.Text, nullable=True), + Column("an_festnetz", sa.Text, nullable=True), + Column("an_mobil", sa.Text, nullable=True), + Column("an_faxnummer", sa.Text, nullable=True), + Column("an_hauptansprechpartner", sa.Text, nullable=True), + Column("an_anrede_anschrift", sa.Text, nullable=True), + Column("an_bemerkung", sa.Text, nullable=True), + Column("an_aktualisierung_datum", SafeDateTime, nullable=True), + Column("an_aktualisierung_nutzer", sa.Text, nullable=True), + Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True), + Column("an_ersteintrag_datum", SafeDateTime, nullable=True), + Column("an_archiviert", sa.Boolean, nullable=True, default=0), + schema="kontaktliste", +) + +# grunderfassung_unternehmen: Table = Table( +# "grunderfassung_unternehmen", +# MD_MAIN, +# Column( +# "un_id", +# sa.Integer, +# primary_key=True, +# autoincrement=True, +# ), +# Column( +# "geloescht", +# UTCDateTime, +# nullable=True, +# ), +# Column( +# "Metadaten_erstellung", +# UTCDateTime, +# nullable=True, +# default=lambda: datetime.datetime.now(datetime.UTC), +# ), +# Column( +# "Metadaten_aktualisierung", +# UTCDateTime, +# nullable=True, +# default=lambda: datetime.datetime.now(datetime.UTC), +# onupdate=lambda: datetime.datetime.now(datetime.UTC), +# ), +# Column("Metadaten_nutzer", sa.String(20), nullable=True), +# Column("Grunderfassung_fallnummer", sa.Text, nullable=True), +# Column("Grunderfassung_notiz", sa.Text, nullable=True), +# Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True, default=None), +# Column("Partnersuche__person_suche", sa.Integer, nullable=True, default=None), +# Column("Partnersuche__un_suche", sa.Integer, nullable=True, default=None), +# ) + +t_unternehmen: Table = Table( + "Unternehmen", MD_MAIN, Column( - "erfassung_id", - sql.Integer, + "un_id", + sa.Integer, primary_key=True, autoincrement=True, ), Column( - "Metadaten_erstellung", + "erstellt", UTCDateTime, - nullable=True, + nullable=False, default=lambda: datetime.datetime.now(datetime.UTC), ), Column( - "Metadaten_aktualisierung", + "geloescht", UTCDateTime, nullable=True, + ), +) + +t_grunderfassung_personen: Table = Table( + "Grunderfassung_Personen", + MD_MAIN, + Column( + "pers_id", + sa.Integer, + primary_key=True, + autoincrement=True, + ), + 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("Metadaten_nutzer", sql.String(20), nullable=True), - Column("Metadaten_wiedereintrittsdatum", sql.Date, nullable=True, default=None), - Column("Arbeitserfahrung", sql.Text, nullable=True), - Column("Grunderfassung_fallnummer", sql.Text, nullable=True), - Column("Grunderfassung_notiz", sql.Text, nullable=True), - Column("HoehereBildung", sql.Text, nullable=True), - Column("Kontaktperson__KP_adresse", sql.Text, nullable=True), - Column("Kontaktperson__KP_anrede_anschrift", sql.Text, nullable=True), - Column("Kontaktperson__KP_email", sql.Text, nullable=True), - Column("Kontaktperson__KP_festnetznummer", sql.Text, nullable=True), - Column("Kontaktperson__KP_funktion_beziehung", sql.Text, nullable=True), - Column("Kontaktperson__KP_mobilfunknummer", sql.Text, nullable=True), - Column("Kontaktperson__KP_name", sql.Text, nullable=True), Column( - "Kontaktperson__KP_name_partner", sql.Text, nullable=True - ), # TODO: check if needed when set by trigger - Column("Kontaktperson__KP_titel", sql.Text, nullable=True), - Column("Kontaktperson__KP_vorname", sql.Text, nullable=True), - Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), - Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), - Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), - Column("Projektrelevanz__relevanz", sql.Text, nullable=True), - Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True), - Column("Schulbildung", sql.Text, nullable=True), - Column("Sprachkenntnisse", sql.Text, nullable=True), - Column("Stammdaten__PLZ", sql.Text, nullable=True), - Column("Stammdaten__anrede_anschrift", sql.Text, nullable=True), - Column("Stammdaten__anzahl_kinder__alter", sql.Text, nullable=True), - Column("Stammdaten__anzahl_kinder__anzahl", sql.Integer, nullable=True), - Column("Stammdaten__aufenthaltsort", sql.Text, nullable=True), - Column("Stammdaten__bundesland", sql.Text, nullable=True), - Column("Stammdaten__land", sql.Text, nullable=True), - Column("Stammdaten__email", sql.Text, nullable=True), - Column("Stammdaten__familienstand", sql.Text, nullable=True), - Column("Stammdaten__festnetznummer", sql.Text, nullable=True), - Column("Stammdaten__geburtsdatum", sql.Date, nullable=True), - Column("Stammdaten__hausnummer", sql.Text, nullable=True), - Column("Stammdaten__herkunftsland", sql.Text, nullable=True), - Column("Stammdaten__mobilfunknummer", sql.Text, nullable=True), - Column("Stammdaten__name", sql.Text, nullable=True), - Column("Stammdaten__ort", sql.Text, nullable=True), - Column("Stammdaten__rueckkehrer", sql.Boolean, nullable=True), - Column("Stammdaten__staatsangehoerigkeit", sql.Text, nullable=True), - Column("Stammdaten__strasse", sql.Text, nullable=True), - Column("Stammdaten__titel", sql.Text, nullable=True), - Column("Stammdaten__vorname", sql.Text, nullable=True), - Column("WeitereInfos__WI_arbeitsstatus", sql.Text, nullable=True), - Column("WeitereInfos__WI_aufenthaltstitel", sql.Text, nullable=True), - Column("WeitereInfos__WI_deutsch_sprache", sql.Text, nullable=True), - Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True), - Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), + "geloescht", + UTCDateTime, + nullable=True, + ), + Column("Metadaten_nutzer", sa.String(20), nullable=True), + Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), + Column("Arbeitserfahrung", sa.Text, nullable=True), + Column("Grunderfassung_fallnummer", sa.Text, nullable=True), + Column("Grunderfassung_notiz", sa.Text, nullable=True), + Column("HoehereBildung", sa.Text, nullable=True), + Column("Kontaktperson__KP_adresse", sa.Text, nullable=True), + Column("Kontaktperson__KP_anrede_anschrift", sa.Text, nullable=True), + Column("Kontaktperson__KP_email", sa.Text, nullable=True), + Column("Kontaktperson__KP_festnetznummer", sa.Text, nullable=True), + Column("Kontaktperson__KP_funktion_beziehung", sa.Text, nullable=True), + Column("Kontaktperson__KP_mobilfunknummer", sa.Text, nullable=True), + Column("Kontaktperson__KP_name", sa.Text, nullable=True), + Column("Kontaktperson__KP_name_partner", sa.Text, nullable=True), + Column("Kontaktperson__KP_titel", sa.Text, nullable=True), + Column("Kontaktperson__KP_vorname", sa.Text, nullable=True), + Column("Projektrelevanz__relevanz", sa.Text, nullable=True), + Column("Projektrelevanz__foerderperiode", sa.Text, nullable=True), + Column("Schulbildung", sa.Text, nullable=True), + Column("Sprachkenntnisse", sa.Text, nullable=True), + Column("Stammdaten__kanal_aufmerksamkeit", sa.Text, nullable=True), + Column("Stammdaten__PLZ", sa.Text, nullable=True), + Column("Stammdaten__anrede_anschrift", sa.Text, nullable=True), + Column("Stammdaten__anzahl_kinder__alter", sa.Text, nullable=True), + Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer, nullable=True), + Column("Stammdaten__aufenthaltsort", sa.Text, nullable=True), + Column("Stammdaten__bundesland", sa.Text, nullable=True), + Column("Stammdaten__land", sa.Text, nullable=True), + Column("Stammdaten__email", sa.Text, nullable=True), + Column("Stammdaten__familienstand", sa.Text, nullable=True), + Column("Stammdaten__festnetznummer", sa.Text, nullable=True), + Column("Stammdaten__geburtsdatum", sa.Date, nullable=True), + Column("Stammdaten__hausnummer", sa.Text, nullable=True), + Column("Stammdaten__herkunftsland", sa.Text, nullable=True), + Column("Stammdaten__mobilfunknummer", sa.Text, nullable=True), + Column("Stammdaten__name", sa.Text, nullable=True), + Column("Stammdaten__ort", sa.Text, nullable=True), + Column("Stammdaten__rueckkehrer", sa.Boolean, nullable=True), + Column("Stammdaten__staatsangehoerigkeit", sa.Text, nullable=True), + Column("Stammdaten__strasse", sa.Text, nullable=True), + Column("Stammdaten__titel", sa.Text, nullable=True), + Column("Stammdaten__vorname", sa.Text, nullable=True), + Column("WeitereInfos__WI_arbeitsstatus", sa.Text, nullable=True), + Column("WeitereInfos__WI_aufenthaltstitel", sa.Text, nullable=True), + Column("WeitereInfos__WI_deutsch_sprache", sa.Text, nullable=True), + Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date, nullable=True), + Column("WeitereInfos__WI_meldung_institution", sa.Text, nullable=True), +) + +t_zuordnung_personen_unternehmen: Table = Table( + "Personen_Unternehmen_Zuordnung", + MD_MAIN, + Column( + "id", + sa.Integer, + primary_key=True, + autoincrement=True, + ), + Column("un_id", sa.ForeignKey("Unternehmen.un_id"), nullable=False), + Column("an_id", sa.Integer, nullable=False), + 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("Partnersuche__kontaktweg", sa.Text, nullable=True), + Index( + "uq_aktive_zuordnung", + "un_id", + "pers_id", + unique=True, + sqlite_where=sql.text("geloescht IS NULL"), + ), +) + +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", + MD_MAIN, + Column( + "vorgang_id", + sa.Integer, + primary_key=True, + autoincrement=True, + ), + Column("un_id", sa.ForeignKey("Unternehmen.un_id"), nullable=True), + Column("pers_id", sa.ForeignKey("Grunderfassung_Personen.pers_id"), nullable=True), + Column("titel", sa.Text, nullable=False), + Column("beratungs_typ", sa.String(20), 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, + ), +) + + +t_beratung_einzelberatung: Table = Table( + "Beratung_Einzelberatung", + MD_MAIN, + Column( + "beratung_id", + sa.Integer, + primary_key=True, + autoincrement=True, + ), + Column("vorgang_id", sa.ForeignKey("Beratung_Vorgang.vorgang_id"), nullable=False), + Column("nutzer_id", sa.Integer, nullable=False), + Column("nutzer_name", sa.String(20), nullable=False), + Column( + "zeitstempel", + UTCDateTime, + nullable=False, + default=lambda: datetime.datetime.now(datetime.UTC), + ), + Column("ansprechpartner", sa.Text, nullable=False), + Column("kommunikationsweg", sa.Text, nullable=False), + Column("thema_crm_matrix", sa.Text, nullable=True), + Column("anmerkungen", sa.Text, nullable=True), + Column("rueckmeldung", sa.Text, nullable=True), + 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, + ), ) diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index 365827c..99bd975 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -4,19 +4,26 @@ import dataclasses as dc import enum import uuid from collections.abc import Sequence -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar import babel from wce_crm.custom_widget_registry import CUSTOM_WIDGET_NAMES +if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + +T = TypeVar("T", bound="QWidget") + class FormFieldType(enum.StrEnum): GROUP = enum.auto() TEXT = enum.auto() LONGTEXT = enum.auto() DATE = enum.auto() + DATE_FLEXIBLE = enum.auto() DATETIME = enum.auto() + DATETIME_FLEXIBLE = enum.auto() DROPDOWN = enum.auto() EXTENDED_DROPDOWN = enum.auto() DYNAMIC_LIST = enum.auto() @@ -29,7 +36,7 @@ class FormFieldType(enum.StrEnum): @dc.dataclass(slots=True) -class FormField: +class FormField(Generic[T]): label: str type: FormFieldType children: Sequence[FormField] = dc.field(default_factory=list) @@ -125,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] = {} @@ -180,7 +187,7 @@ COUNTRY_LIST: Final[CountryList] = get_country_list_german() GERMAN_STATE_LIST: Final[CountryList] = get_list_germany_states() -INITREC_COMP_SEARCH_HEAD = [ +_INITREC_COMP_SEARCH_HEAD = [ FormField( "Suche", FormFieldType.EXTENDED_DROPDOWN, @@ -300,52 +307,38 @@ INITREC_COMP_SEARCH_HEAD = [ info="an_position", ), FormField( - "Wie sind Sie auf uns aufmerksam geworden?", + "Kontaktweg", FormFieldType.DROPDOWN, required=False, - key="kanal_aufmerksamkeit", + key="kontaktweg", options=[ - ("Agentur für Arbeit", None), - ("Ausländerbehörde", None), - ("Jobcenter", None), - ("Freunde/Familie", None), - ("Anerkennungsstelle", None), - ("Beratungsstelle", None), - ("Internet", None), - ("Arbeitgeber", None), - ("Bildungsdienstleister", None), - ("Welcome-Mappe", None), - ("Newsletter WFE", None), - ("Newsletter RM", None), - ("Sonstiges", None), + ("Telefon", None), + ("Mail", None), + ("Persönliches Gespräch", None), ], ), ] -INITREC_CONTACT_PERSON = [ +_INITREC_CONTACT_PERSON = [ FormField( - "Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)", + "Name Unternehmen/Netzwerkpartner", FormFieldType.TEXT, key="KP_name_partner", required=False, - placeholder="Text wird nach gewähltem Unternehmen angezeigt", - readonly=True, + readonly=False, ), FormField( "Titel", FormFieldType.TEXT, key="KP_titel", required=False, - tooltip=( - "* nur wenn anrufende Person oder kontaktaufnehmende Person " - "nicht die zu beratende Person ist" - ), ), FormField( - "Anrede_Anschrift", - FormFieldType.TEXT, + "Anrede", + FormFieldType.DROPDOWN, key="KP_anrede_anschrift", required=True, + options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)], ), FormField( "Name", @@ -392,22 +385,40 @@ INITREC_CONTACT_PERSON = [ ] -INITREC_MASTER_DATA = [ +_INITREC_MASTER_DATA = [ + FormField( + "Wie sind Sie auf uns aufmerksam geworden?", + FormFieldType.DROPDOWN, + required=False, + key="kanal_aufmerksamkeit", + options=[ + ("Agentur für Arbeit", None), + ("Ausländerbehörde", None), + ("Jobcenter", None), + ("Freunde/Familie", None), + ("Anerkennungsstelle", None), + ("Beratungsstelle", None), + ("Internet", None), + ("Arbeitgeber", None), + ("Bildungsdienstleister", None), + ("Welcome-Mappe", None), + ("Newsletter WFE", None), + ("Newsletter RM", None), + ("Sonstiges", None), + ], + ), FormField( "Titel", FormFieldType.TEXT, key="titel", required=False, - tooltip=( - "* nur wenn anrufende Person oder kontaktaufnehmende Person " - "nicht die zu beratende Person ist" - ), ), FormField( "Anrede", - FormFieldType.TEXT, + FormFieldType.DROPDOWN, key="anrede_anschrift", required=True, + options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)], ), FormField( "Name", @@ -419,17 +430,13 @@ INITREC_MASTER_DATA = [ "Vorname", FormFieldType.TEXT, key="vorname", - required=False, + required=True, ), FormField( "Geburtsdatum", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, key="geburtsdatum", required=False, - tooltip=( - "* Wichtig zu erfragen, da u.a. Mindestgehaltsschwelle davon abhängt " - "(Regelung bei Ü45 Jahre)" - ), ), FormField( "Herkunftsland", @@ -438,7 +445,6 @@ INITREC_MASTER_DATA = [ required=True, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Staatsangehörigkeit", @@ -447,7 +453,6 @@ INITREC_MASTER_DATA = [ required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Rückkehrer", @@ -455,7 +460,6 @@ INITREC_MASTER_DATA = [ key="rueckkehrer", required=False, options=[("ja", None), ("nein", None)], - tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Wo befindet sich die Person?", @@ -494,7 +498,6 @@ INITREC_MASTER_DATA = [ key="bundesland", required=False, options=GERMAN_STATE_LIST.for_dropdown, - tooltip=("nur wenn Inland angegeben"), ), FormField( "Land", @@ -527,13 +530,11 @@ INITREC_MASTER_DATA = [ FormFieldType.TEXT, key="familienstand", required=False, - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", ), FormField( "Anzahl Kinder", FormFieldType.DYNAMIC_DROPDOWN_NUMERIC, required=False, - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl_kinder", children=[ FormField( @@ -541,7 +542,6 @@ INITREC_MASTER_DATA = [ FormFieldType.DROPDOWN, required=False, options=[(str(x), None) for x in range(11)], - tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl", children=[ FormField("Alter Kind", FormFieldType.TEXT, key="alter"), @@ -552,7 +552,7 @@ INITREC_MASTER_DATA = [ ] -INITREC_ADDITIONAL_DATA = [ +_INITREC_ADDITIONAL_DATA = [ FormField( "Deutsch als Kommunikationssprache", FormFieldType.DROPDOWN, @@ -585,11 +585,10 @@ INITREC_ADDITIONAL_DATA = [ ("Chancenaufenthaltsrecht §104c AufenthG", None), ("Sonstiges", None), ], - tooltip="sofern nicht bekannt, unbedingt einfordern", ), FormField( "Gültigkeit Aufenthaltsstatus", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="WI_gueltigkeit_aufenthaltstitel", ), @@ -627,7 +626,7 @@ INITREC_ADDITIONAL_DATA = [ ] -INITREC_SCHOOL = [ +_INITREC_SCHOOL = [ FormField("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"), FormField( "Abschlussgrad laut Dokument", @@ -665,7 +664,7 @@ INITREC_SCHOOL = [ ] -INITREC_HIGHER_EDUCATION = [ +_INITREC_HIGHER_EDUCATION = [ FormField( "Anerkennung", FormFieldType.TEXT, @@ -677,10 +676,6 @@ INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_abschlussgrad", - tooltip=( - "bitte den Titel eingeben z.B. Doktor, Diplom oder " - "Betriebswirt (Fachschulabschluss)" - ), ), FormField( "Abschlussgrad laut Dokument", @@ -699,10 +694,6 @@ INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_beruf", - tooltip=( - "bitte spezifizieren z.B. Allgemeinmedizin, Ingenieur Maschinenbau, " - "technischer Betriebswirt Datenverarbeitung" - ), ), FormField( "Land", @@ -729,12 +720,11 @@ INITREC_HIGHER_EDUCATION = [ FormFieldType.TEXT, required=False, key="HB_bemerkungsfeld", - tooltip="z.B. Promotionen oder den Studiengang angeben", ), ] -INITREC_WORK_EXPERIENCE = [ +_INITREC_WORK_EXPERIENCE = [ FormField( "Branche", FormFieldType.DROPDOWN, @@ -816,7 +806,6 @@ INITREC_WORK_EXPERIENCE = [ ("Teilzeit", None), ("Sonstiges", None), ], - tooltip="Minijob, Praktikum, Wehrdienst, soziale Dienste", ), FormField( "Bemerkungsfeld", @@ -827,7 +816,7 @@ INITREC_WORK_EXPERIENCE = [ ] -INITREC_LANGUAGES = [ +_INITREC_LANGUAGES = [ FormField( "Sprache", FormFieldType.TEXT, @@ -869,7 +858,7 @@ INITREC_LANGUAGES = [ ), FormField( "Datum des Nachweises", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="SP_datum_nachweis", ), @@ -881,120 +870,16 @@ INITREC_LANGUAGES = [ INITREC_COMP = [ - FormField( - "Ersteintrag Datum", - FormFieldType.TEXT_DATETIME, - required=False, - key="Metadaten_erstellung", - readonly=True, - ignore_get_data=True, - ), - FormField( - "Aktualisierung Datum", - FormFieldType.TEXT_DATETIME, - required=False, - key="Metadaten_aktualisierung", - readonly=True, - ignore_get_data=True, - ), - FormField( - "Aktualisierung Nutzer", - FormFieldType.TEXT, - required=False, - key="Metadaten_nutzer", - readonly=True, - ), - FormField( - "Fallnummer", - FormFieldType.TEXT, - required=True, - key="Grunderfassung_fallnummer", - ), - FormField( - "Notizen", - FormFieldType.LONGTEXT, - required=False, - key="Grunderfassung_notiz", - ), FormField( "Suche", FormFieldType.CUSTOM, - custom_widget="grunderfassung_suche", + custom_widget="grunderfassung_unternehmen_suche", key="Partnersuche", - children=INITREC_COMP_SEARCH_HEAD, - ), - FormField( - "Status && Projektrelevanz", - FormFieldType.GROUP, - key="Projektrelevanz", - children=[ - FormField( - "Projektrelevanz", - FormFieldType.DYNAMIC_DROPDOWN_OPTION, - key="", - trigger_value="ja", - children=[ - FormField( - "Relevanz", - FormFieldType.DROPDOWN, - required=True, - options=[("ja", None), ("nein", None)], - key="relevanz", - children=[ - FormField( - "Förderperiode", FormFieldType.TEXT, key="foerderperiode" - ), - ], - ), - ], - ), - ], - ), - FormField( - "Daten Kontaktperson", - FormFieldType.GROUP, - key="Kontaktperson", - children=INITREC_CONTACT_PERSON, - ), - FormField( - "Stammdaten", - FormFieldType.GROUP, - key="Stammdaten", - children=INITREC_MASTER_DATA, - ), - FormField( - "Weitere Informationen", - FormFieldType.GROUP, - key="WeitereInfos", - children=INITREC_ADDITIONAL_DATA, - ), - FormField( - "Schulbildung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_SCHOOL, - key="Schulbildung", - ), - FormField( - "Studium/Ausbildung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_HIGHER_EDUCATION, - key="HoehereBildung", - ), - FormField( - "Arbeitserfahrung", - FormFieldType.DYNAMIC_LIST, - children=INITREC_WORK_EXPERIENCE, - key="Arbeitserfahrung", - ), - FormField( - "Sprachkenntnisse", - FormFieldType.DYNAMIC_LIST, - children=INITREC_LANGUAGES, - key="Sprachkenntnisse", + children=_INITREC_COMP_SEARCH_HEAD, ), ] -INITREC_PERSON = [ +INITREC_METADATEN = [ FormField( "Ersteintrag Datum", FormFieldType.TEXT_DATETIME, @@ -1017,10 +902,11 @@ INITREC_PERSON = [ required=False, key="Metadaten_nutzer", readonly=True, + ignore_get_data=False, ), FormField( "Wiedereintrittsdatum", - FormFieldType.DATE, + FormFieldType.DATE_FLEXIBLE, required=False, key="Metadaten_wiedereintrittsdatum", readonly=False, @@ -1037,6 +923,9 @@ INITREC_PERSON = [ required=False, key="Grunderfassung_notiz", ), +] + +INITREC_PERSON = [ FormField( "Status && Projektrelevanz", FormFieldType.GROUP, @@ -1049,7 +938,7 @@ INITREC_PERSON = [ trigger_value="ja", children=[ FormField( - "Relevanz", + "Relevant perspektivERZ", FormFieldType.DROPDOWN, required=True, options=[("ja", None), ("nein", None)], @@ -1068,42 +957,42 @@ INITREC_PERSON = [ "Daten Kontaktperson", FormFieldType.GROUP, key="Kontaktperson", - children=INITREC_CONTACT_PERSON, + children=_INITREC_CONTACT_PERSON, ), FormField( "Stammdaten", FormFieldType.GROUP, key="Stammdaten", - children=INITREC_MASTER_DATA, + children=_INITREC_MASTER_DATA, ), FormField( "Weitere Informationen", FormFieldType.GROUP, key="WeitereInfos", - children=INITREC_ADDITIONAL_DATA, + children=_INITREC_ADDITIONAL_DATA, ), FormField( "Schulbildung", FormFieldType.DYNAMIC_LIST, - children=INITREC_SCHOOL, + children=_INITREC_SCHOOL, key="Schulbildung", ), FormField( "Studium/Ausbildung", FormFieldType.DYNAMIC_LIST, - children=INITREC_HIGHER_EDUCATION, + children=_INITREC_HIGHER_EDUCATION, key="HoehereBildung", ), FormField( "Arbeitserfahrung", FormFieldType.DYNAMIC_LIST, - children=INITREC_WORK_EXPERIENCE, + children=_INITREC_WORK_EXPERIENCE, key="Arbeitserfahrung", ), FormField( "Sprachkenntnisse", FormFieldType.DYNAMIC_LIST, - children=INITREC_LANGUAGES, + children=_INITREC_LANGUAGES, key="Sprachkenntnisse", ), ] diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index b5ebef9..f5f27e2 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3,72 +3,149 @@ from __future__ import annotations import copy import dataclasses as dc import datetime -import pickle import re import sys import traceback from collections import defaultdict -from collections.abc import Container, Iterable, Sequence -from pathlib import Path +from collections.abc import Callable, Container, Iterable, Sequence from pprint import pformat -from typing import Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + Protocol, + TypeAlias, + TypedDict, + TypeVar, + cast, +) from typing_extensions import override +from dopt_basics.result_pattern import STATUS_HANDLER from pydantic import ( ValidationError, ) from PySide6.QtCore import ( QDate, + QDateTime, QEvent, + QKeyCombination, + QLibraryInfo, + QLocale, QObject, Qt, QTimer, QtMsgType, + QTranslator, Signal, qInstallMessageHandler, ) -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QFont, QKeySequence from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, + QBoxLayout, + QButtonGroup, + QCalendarWidget, QComboBox, QCompleter, QDateEdit, + QDialog, + QDialogButtonBox, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLayout, QLineEdit, + QListWidget, + QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, + QRadioButton, QScrollArea, QSizePolicy, QStackedWidget, + QTableWidget, + QTableWidgetItem, + QToolButton, QVBoxLayout, QWidget, ) import wce_crm.constants +from wce_crm import types from wce_crm.backend import backend -from wce_crm.data_models import COLUMN_SEP, FlatBaseModel, Grunderfassung +from wce_crm.constants import COLUMN_SEP, TIMEZONE_CEST +from wce_crm.data_models import ( + 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, + Page_MainPage_State, + Page_NewEntry_State, + Session, + set_page_state, +) from wce_crm.form_defs import ( - INITREC_COMP, - INITREC_PERSON, 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_auto_form, + logger_custom_fields, logger_get_data, logger_gui, + logger_initrecform, + logger_page_consulting, + logger_page_initrec, ) +from wce_crm.types import ( + CompanyProfileConsultationEntry, + ConsultingType, + EntityIds, + EntityType, + RecId, + RecordingType, +) + +if TYPE_CHECKING: + from wce_crm.data_models import ( + Module, + Page_InitRec_Form_State, + ) + from wce_crm.types import ( + ConsId, + LinkingConsultationsEntryCompany, + LinkingConsultationsEntryPerson, + ) K = TypeVar("K") V = TypeVar("V") +T = TypeVar("T") +W = TypeVar("W", bound="QWidget") DEBUG: bool = True @@ -94,47 +171,80 @@ QSS = """ } *[styleClass="stempel"]:focus { - border: 1px dashed #cbd5e1; + border: 1px solid #cbd5e1; +} + +QPushButton[styleClass="addButton"] { + background-color: #007acc; + color: white; + border: none; + border-radius: 6px; + padding: 8px 15px; + font-weight: bold; +} + +QPushButton[styleClass="addButton"]:hover { + background-color: #0098ff; +} + +QPushButton[styleClass="addButton"]:disabled { + background-color: #cbd5e1; + color: #94a3b8; +} + +QPushButton[styleClass="outlineAddButton"] { + background-color: transparent; + color: #007acc; + border: 1px solid #007acc; + border-radius: 6px; + padding: 5px 12px; + font-weight: bold; +} + +QPushButton[styleClass="outlineAddButton"]:hover { + background-color: #007acc; + color: white; +} + +QPushButton[styleClass="outlineAddButton"]:disabled { + /* background-color: #cbd5e1; */ + background-color: transparent; + color: #94a3b8; + border: none; +} + +QPushButton[styleClass="softAddButton"] { + background-color: #e0f2fe; + color: #0369a1; + border: none; + border-radius: 6px; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="softAddButton"]:hover { + background-color: #bae6fd; + color: #0369a1; +} + +QPushButton[styleClass="flatAddButton"] { + background-color: transparent; + color: #475569; + border: none; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="flatAddButton"]:hover { + background-color: #f1f5f9; + color: #007acc; } """ DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]") DATETIME_FMT: Final[str] = "%d.%m.%Y %H:%M:%S" DATE_FMT: Final[str] = "%d.%m.%Y" - - -# TODO check removal -def _save_pydantic_model_dict_db( - model: FlatBaseModel, - path: Path | None = None, -) -> None: - if path is None: - path = Path(__file__).parent / "model.pkl" - elif not path.is_file(): - raise ValueError("Path must point to a file") - - export = model.to_db() - - with open(path, "wb") as f: - pickle.dump(export, f) - - -# TODO check removal -def _load_pydantic_model_dict_db( - path: Path | None = None, -) -> dict[str, Any]: - if path is None: - path = Path(__file__).parent / "model.pkl" - elif not path.is_file(): - raise ValueError("Path must point to a file") - - if not path.exists(): - raise FileNotFoundError(f"Provided path for model dict not found: >{path}<") - - with open(path, "rb") as f: - model_dict = pickle.load(f) - - return model_dict +DEFAULT_READONLY_STYLECLASS: Final[str] = "stempel" def merge_dicts_to_lists( @@ -161,36 +271,6 @@ def unmerge_dict_to_list( return [dict(zip(keys, row)) for row in zip(*value_lists)] -# TODO check removal -def _get_leafs(data): - if isinstance(data, dict): - for value in data.values(): - yield from _get_leafs(value) - elif isinstance(data, (list, tuple, set)): - for item in data: - yield from _get_leafs(item) - else: - yield data - - -# TODO check removal -def _get_leaf_dicts(data): - if isinstance(data, dict): - has_inner_dicts = False - - for value in data.values(): - for inner_dict in _get_leaf_dicts(value): - has_inner_dicts = True - yield inner_dict - - if not has_inner_dicts: - yield data - - elif isinstance(data, (list, tuple, set)): - for item in data: - yield from _get_leaf_dicts(item) - - def pprint_registry(widget_registry: WidgetRegistry) -> None: print("\n\n>>> Widget registry:") for key, entry in widget_registry.items(): @@ -213,57 +293,20 @@ def pformat_registry(widget_registry: WidgetRegistry) -> str: class CustomForm(Protocol): - def get_form_data(self) -> dict[str, Any]: ... + def get_form_data(self) -> dict[str, Any] | list[dict[str, Any]]: ... def set_form_data(self, data: Any) -> None: ... - def reset_form(self) -> None: ... + def reset(self) -> None: ... - def validate_form_data(self) -> list[str]: ... + def validate(self) -> list[str]: ... + + def lock(self) -> None: ... + + def unlock(self) -> None: ... -class AutoFormInsert(Protocol): - def __call__( - self, - data: dict[str, Any], - ) -> backend.InitRecId: ... - - -class AutoFormUpdate(Protocol): - def __call__( - self, - id_: int, - data: dict[str, Any], - ) -> None: ... - - -class AutoFormGet(Protocol): - def __call__( - self, - id_: int, - ) -> dict[str, Any]: ... - - -class AutoFormDelete(Protocol): - def __call__( - self, - id_: int, - ) -> None: ... - - -@dc.dataclass(slots=True) -class AutoFormConfig: - model: type[FlatBaseModel] - data_insert: AutoFormInsert - data_update: AutoFormUpdate - data_get: AutoFormGet - data_delete: AutoFormDelete - form_fields: Sequence[FormField] - ignored_keys: Iterable[str] = tuple() - add_buttons: bool = True - - -class CustomWidget(QWidget): +class CustomFormWidget(QWidget): def __init__( self, form_fields: Sequence[FormField], @@ -279,9 +322,249 @@ class CustomWidget(QWidget): def set_form_data(self, data: Any) -> None: ... - def reset_form(self) -> None: ... + def reset(self) -> None: ... - def validate_form_data(self) -> list[str]: ... + def validate(self) -> list[str]: ... + + def lock(self) -> None: ... + + def unlock(self) -> None: ... + + +def _add_widget_to_layout( + form_field: FormField[W], + widget: QWidget, + target_layout: QLayout, + widget_only: bool = False, +) -> None: + + if isinstance(target_layout, QFormLayout): + if widget_only: + target_layout.addRow(widget) + elif form_field.tooltip: + tooltip_layout = _add_tooltip(widget, form_field.tooltip) + target_layout.addRow(form_field.label, tooltip_layout) + else: + target_layout.addRow(form_field.label, widget) + elif isinstance(target_layout, QBoxLayout): + if widget_only: + target_layout.addWidget(widget) + elif form_field.tooltip: + tooltip_layout = _add_tooltip(widget, form_field.tooltip) + target_layout.addLayout(tooltip_layout) + else: + target_layout.addWidget(widget) + + +def build_and_add_widget( + form_field: FormField[W], + parent_layout: QLayout, + widget_registry: WidgetRegistry | None = None, + prefix: str = "", +) -> tuple[str, W]: + no_scroll_filter = NoScrollFilter(parent_layout) + if prefix and form_field.key: + full_key = f"{prefix}{COLUMN_SEP}{form_field.key}" if prefix else form_field.key + elif prefix and not form_field.key: + full_key = f"{prefix}" + else: + full_key = form_field.key + + layout_widget_only: bool = False + install_no_scroll_filter: bool = True + + match form_field.type: + case ( + FormFieldType.DYNAMIC_LIST + | FormFieldType.DYNAMIC_DROPDOWN_NUMERIC + | FormFieldType.DYNAMIC_DROPDOWN_OPTION + | FormFieldType.CUSTOM + ): + layout_widget_only = True + install_no_scroll_filter = False + + match form_field.type: + case FormFieldType.GROUP: + raise TypeError("FormField Groups are not supported for direct building") + + case FormFieldType.TEXT | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME: + widget = QLineEdit() + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setText(form_field.fill_value) + if form_field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.TEXT_SEARCH: + widget = QLineEdit() + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + else: + widget.setPlaceholderText("Tippen zum Suchen...") + + search_completer = QCompleter() + search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + search_completer.setFilterMode(Qt.MatchFlag.MatchContains) + widget.setCompleter(search_completer) + + case FormFieldType.LONGTEXT: + widget = QPlainTextEdit() + widget.setMaximumHeight(80) + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.DATE: + widget = QDateEdit() + widget.setCalendarPopup(True) + cal = widget.calendarWidget() + if cal: + cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + cal.setGridVisible(True) + cal.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.setDate(set_date) + else: + widget.setDate(QDate.currentDate()) + + if form_field.readonly: + widget = QLineEdit() + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + if form_field.fill_value: + widget.setText(form_field.fill_value) + + case FormFieldType.DATE_FLEXIBLE: + widget = FlexibleDateInput() + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_date(set_date) + + if form_field.readonly: + widget.setReadOnly(True) + if form_field.required: + widget.setRequired(True) + + case FormFieldType.DATETIME_FLEXIBLE: + widget = FlexibleDateTimeInput() + if form_field.fill_value: + set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm") + if not set_datetime.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_datetime(set_datetime) + + if form_field.readonly: + widget.setReadOnly(True) + if form_field.required: + widget.setRequired(True) + + case FormFieldType.DROPDOWN: + widget = QComboBox() + widget.addItem(DROPDOWN_DEFAULT, None) + for option in form_field.dropdown_options: + widget.addItem(option.label, option.data) + + if form_field.placeholder: + widget.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setCurrentText(form_field.fill_value) + else: + widget.setCurrentIndex(0) + if form_field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.EXTENDED_DROPDOWN: + widget = QComboBox() + widget.setEditable(True) + widget.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + completer = widget.completer() + assert completer + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + completer.setFilterMode(Qt.MatchFlag.MatchContains) + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + + widget.addItem(DROPDOWN_DEFAULT, None) + for option in form_field.dropdown_options: + widget.addItem(option.label, option.data) + + if form_field.placeholder: + line_edit = widget.lineEdit() + assert line_edit + line_edit.setPlaceholderText(form_field.placeholder) + if form_field.fill_value: + widget.setCurrentText(form_field.fill_value) + else: + widget.setCurrentIndex(-1) + if form_field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + case FormFieldType.DYNAMIC_LIST: + widget = DynamicListWidget( + form_field.children, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.DYNAMIC_DROPDOWN_NUMERIC: + widget = DynamicDropdownWidgetNumeric( + form_field.children, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.DYNAMIC_DROPDOWN_OPTION: + widget = DynamicDropdownWidgetOption( + form_field.children, + form_field.trigger_value, + form_field.label, + prefix=f"{full_key}", + ) + + case FormFieldType.CUSTOM: + widget_class = CUSTOM_WIDGETS[form_field.custom_widget] + widget = widget_class( + form_fields=form_field.children, + label=form_field.label, + prefix=f"{full_key}", + ) + + case _: + raise NotImplementedError(f"Not supported field type: {form_field.type.value}") + + if install_no_scroll_filter: + widget.installEventFilter(no_scroll_filter) + + _add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only) + + if widget_registry is not None: + widget_registry[full_key] = { + "widget": widget, + "form_field": form_field, + } + + return full_key, cast(W, widget) def _build_ui_recursively( @@ -290,18 +573,15 @@ def _build_ui_recursively( widget_registry: WidgetRegistry, prefix: str = "", ) -> list[str]: - no_scroll_filter = NoScrollFilter(parent_layout) keys: list[str] = [] for field in schema: if prefix and field.key: full_key = f"{prefix}{COLUMN_SEP}{field.key}" if prefix else field.key elif prefix and not field.key: - full_key = f"{prefix}" + full_key = prefix else: full_key = field.key - widget: QWidget - match field.type: case FormFieldType.GROUP: group_box = QGroupBox(field.label) @@ -314,235 +594,14 @@ def _build_ui_recursively( ) parent_layout.addRow(group_box) - case FormFieldType.TEXT | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME: - widget = QLineEdit() - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setText(field.fill_value) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.TEXT_SEARCH: - widget = QLineEdit() - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - else: - widget.setPlaceholderText("Tippen zum Suchen...") - - search_completer = QCompleter() - search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - search_completer.setFilterMode(Qt.MatchFlag.MatchContains) - widget.setCompleter(search_completer) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.LONGTEXT: - widget = QPlainTextEdit() - widget.setMaximumHeight(80) - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DATE: - widget = QDateEdit() - widget.setCalendarPopup(True) - cal = widget.calendarWidget() - if cal: - cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday) - cal.setGridVisible(True) - cal.setStyleSheet(""" - QCalendarWidget QAbstractItemView { - selection-background-color: #2980b9; - selection-color: white; - } - """) - if field.fill_value: - set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") - if not set_date.isValid(): - raise ValueError( - f"Could not parse date field value >{field.fill_value}<" - ) - widget.setDate(set_date) - else: - widget.setDate(QDate.currentDate()) - - if field.readonly: - widget = QLineEdit() - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - if field.fill_value: - widget.setText(field.fill_value) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DROPDOWN: - widget = QComboBox() - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(0) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.EXTENDED_DROPDOWN: - widget = QComboBox() - widget.setEditable(True) - widget.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - completer = widget.completer() - assert completer - completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - completer.setFilterMode(Qt.MatchFlag.MatchContains) - completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - line_edit = widget.lineEdit() - assert line_edit - line_edit.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(-1) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - widget.installEventFilter(no_scroll_filter) - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DYNAMIC_LIST: - widget = DynamicListWidget( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.DYNAMIC_DROPDOWN_NUMERIC: - widget = DynamicDropdownWidgetNumeric( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.DYNAMIC_DROPDOWN_OPTION: - widget = DynamicDropdownWidgetOption( - field.children, - field.trigger_value, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.CUSTOM: - widget_class = CUSTOM_WIDGETS[field.custom_widget] - widget = widget_class( - form_fields=field.children, - label=field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - case _: - raise NotImplementedError(f"Not supported field type: {field.type.value}") - - keys.append(full_key) + full_key, _ = build_and_add_widget( + field, + parent_layout, + widget_registry=widget_registry, + prefix=prefix, + ) + keys.append(full_key) return keys @@ -573,7 +632,7 @@ def reset_form( form_field = registry_entry["form_field"] # TODO check if this behaviour is correct in other contexts, deactivated because of - # TODO company search widget + # company search widget # if form_field.readonly: # continue @@ -593,6 +652,24 @@ def reset_form( widget.setDate(set_date) else: widget.setDate(QDate.currentDate()) + elif isinstance(widget, FlexibleDateInput): + widget.reset() + if form_field.fill_value: + set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_date(set_date) + elif isinstance(widget, FlexibleDateTimeInput): + widget.reset() + if form_field.fill_value: + set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm") + if not set_datetime.isValid(): + raise ValueError( + f"Could not parse date field value >{form_field.fill_value}<" + ) + widget.set_datetime(set_datetime) elif isinstance(widget, QComboBox): if form_field.fill_value: widget.setCurrentText(form_field.fill_value) @@ -606,11 +683,11 @@ def reset_form( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, - CustomWidget, + CustomFormWidget, ), ): # custom widget classes manage their widgets on their own - widget.reset_form() + widget.reset() widget.setStyleSheet("") @@ -701,36 +778,12 @@ def get_form_data( ignored_keys.append(key) continue - if isinstance(widget, QLineEdit): - data = widget.text() - if data != "": - value = data - if form_field.type is FormFieldType.TEXT_DATE: - value = datetime.datetime.strptime(value, DATE_FMT).date() - elif form_field.type is FormFieldType.TEXT_DATETIME: - value = datetime.datetime.strptime(value, DATETIME_FMT) - elif isinstance(widget, QPlainTextEdit): - data = widget.toPlainText() - if data != "": - value = data - elif isinstance(widget, QDateEdit): - qt_date = widget.date() - value = qt_date.toPython() - elif isinstance(widget, QComboBox): - value = widget.currentData() - elif isinstance( - widget, - ( - DynamicListWidget, - DynamicDropdownWidgetNumeric, - DynamicDropdownWidgetOption, - CustomWidget, - ), - ): - # this is a special data structure with some assumptions of the widget's internals - # DynamicListWidget: this is a list -> each dynamic list contains a list - # of such dictionaries (handled below) - value = widget.get_form_data() + if form_field.type is FormFieldType.TEXT_DATE: + value = get_widget_value(widget, "date") + elif form_field.type is FormFieldType.TEXT_DATETIME: + value = get_widget_value(widget, "datetime") + else: + value = get_widget_value(widget) if isinstance(value, dict): raw_data.update(value) @@ -742,6 +795,50 @@ def get_form_data( return raw_data +def get_widget_value( + widget: QWidget, + text_type: Literal["default", "date", "datetime"] = "default", +) -> Any: + value: Any | None = None + + if isinstance(widget, QLineEdit): + data = widget.text() + if data != "": + value = data + if text_type == "date": + value = datetime.datetime.strptime(value, DATE_FMT).date() + elif text_type == "datetime": + value = datetime.datetime.strptime(value, DATETIME_FMT) + elif isinstance(widget, QPlainTextEdit): + data = widget.toPlainText() + if data != "": + value = data + elif isinstance(widget, QDateEdit): + qt_date = widget.date() + value = qt_date.toPython() + elif isinstance(widget, FlexibleDateInput): + value = widget.get_pydate() + elif isinstance(widget, FlexibleDateTimeInput): + value = widget.get_pydatetime() + elif isinstance(widget, QComboBox): + value = widget.currentData() + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomFormWidget, + ), + ): + # this is a special data structure with some assumptions of the widget's internals + # DynamicListWidget: this is a list -> each dynamic list contains a list + # of such dictionaries (handled below) + value = widget.get_form_data() + + return value + + def set_widget_value( widget: QWidget, value: Any, @@ -771,7 +868,18 @@ def set_widget_value( if not set_date.isValid(): raise ValueError(f"Could not parse date field value >{value}<") widget.setDate(set_date) - + elif isinstance(widget, FlexibleDateInput): + assert isinstance(value, (datetime.datetime, datetime.date)) or value is None + try: + widget.set_pydate(value, raise_error=True) + except ValueError: + raise ValueError(f"Could not parse date field value >{value}<") + elif isinstance(widget, FlexibleDateTimeInput): + assert isinstance(value, datetime.datetime) or value is None + try: + widget.set_pydatetime(value, raise_error=True) + except ValueError: + raise ValueError(f"Could not parse datetime field value >{value}<") elif isinstance(widget, QComboBox): index = widget.findData(value) if index >= 0: @@ -833,6 +941,7 @@ def validate_form_data( for key, registry_entry in widget_registry.items(): error_post: bool = False + custom_formatting: bool = False widget = registry_entry["widget"] form_field = registry_entry["form_field"] @@ -864,16 +973,20 @@ def validate_form_data( if widget.currentData() is not None: continue error_post = True + elif isinstance(widget, (FlexibleDateInput, FlexibleDateTimeInput)): + valid = widget.validate() + error_post = not valid + custom_formatting = True elif isinstance( widget, ( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, - CustomWidget, + CustomFormWidget, ), ): - errors_widget = widget.validate_form_data() + errors_widget = widget.validate() if not errors_widget: continue errors.extend(errors_widget) @@ -891,17 +1004,108 @@ def validate_form_data( error = error.replace("&&", "&") errors.append(error) # optical feedback to highlight erroneous cells - widget.setStyleSheet(""" - border: 1px solid #ef4444; - background-color: #ffe9e9; - padding: 4px; - border-radius: 4px; - """) + if not custom_formatting: + widget.setStyleSheet(""" + border: 1px solid #ef4444; + background-color: #ffe9e9; + padding: 4px; + border-radius: 4px; + """) return errors -class Grunderfassung_SuchWidget(CustomWidget): +def validate_child_modules( + modules: Iterable[Module], +) -> list[str]: + errors: list[str] = [] + for mod in modules: + errors.extend(mod.validate()) + + return errors + + +def lock_form( + widget_registry: WidgetRegistry, +) -> None: + for registry_entry in widget_registry.values(): # type: ignore + widget = registry_entry["widget"] + form_field = registry_entry["form_field"] + + if form_field.readonly: + continue + + if isinstance( + widget, + ( + QLineEdit, + QPlainTextEdit, + QDateEdit, + FlexibleDateInput, + FlexibleDateTimeInput, + ), + ): + widget.setReadOnly(True) + elif isinstance(widget, QComboBox): + widget.setEnabled(False) + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomFormWidget, + ), + ): + widget.lock() + + widget.setProperty("styleClass", "stempel") + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +def unlock_form( + widget_registry: WidgetRegistry, +) -> None: + for registry_entry in widget_registry.values(): # type: ignore + widget = registry_entry["widget"] + form_field = registry_entry["form_field"] + + if form_field.readonly: + continue + + if isinstance( + widget, + ( + QLineEdit, + QPlainTextEdit, + QDateEdit, + FlexibleDateInput, + FlexibleDateTimeInput, + ), + ): + widget.setReadOnly(False) + elif isinstance(widget, QComboBox): + widget.setEnabled(True) + elif isinstance( + widget, + ( + DynamicListWidget, + DynamicDropdownWidgetNumeric, + DynamicDropdownWidgetOption, + CustomFormWidget, + ), + ): + widget.unlock() + + widget.setProperty("styleClass", "") + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +class Grunderfassung_SuchWidget(CustomFormWidget): def __init__( self, form_fields: Sequence[FormField], @@ -945,15 +1149,15 @@ class Grunderfassung_SuchWidget(CustomWidget): self.person_search_input = cast(QComboBox, lookup[0]["widget"]) self.person_search_input.currentIndexChanged.connect(self._selected_person) - self.CHANNEL_AWARENESS_KEY = "kanal_aufmerksamkeit" - lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_AWARENESS_KEY) + self.CHANNEL_CONTACT_KEY = "kontaktweg" + lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_CONTACT_KEY) assert len(lookup) == 1 - self.channel_awareness = cast(QComboBox, lookup[0]["widget"]) + self.channel_contact = cast(QComboBox, lookup[0]["widget"]) self.DATA_EXPORT_FIELDS = ( self.COMPANY_SEARCH_INPUT_KEY, self.PERSON_SEARCH_INPUT_KEY, - self.CHANNEL_AWARENESS_KEY, + self.CHANNEL_CONTACT_KEY, ) self.company_widgets: dict[str, QWidget] = {} @@ -990,7 +1194,7 @@ class Grunderfassung_SuchWidget(CustomWidget): button.clicked.connect(self.print_registry) main_layout.addWidget(button) button = QPushButton("Reset form") - button.clicked.connect(self.reset_form) + button.clicked.connect(self.reset) main_layout.addWidget(button) button = QPushButton("Get form data") button.clicked.connect(self.get_form_data) @@ -999,7 +1203,7 @@ class Grunderfassung_SuchWidget(CustomWidget): button.clicked.connect(self.set_form_data) main_layout.addWidget(button) button = QPushButton("Validate") - button.clicked.connect(self.validate_form_data) + button.clicked.connect(self.validate) main_layout.addWidget(button) self.update_company_data() @@ -1009,7 +1213,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_company( self, - data: backend.CompanyInfo, + data: types.CompanyInfo, ) -> None: for key, widget in self.company_widgets.items(): if key not in data: @@ -1023,7 +1227,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_person( self, - data: backend.ContactPersonInfo, + data: types.ContactPersonInfo, ) -> None: for key, widget in self.person_widgets.items(): if key not in data: @@ -1037,7 +1241,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def _clear_company_fields(self) -> None: self.person_search_input.clear() - self.channel_awareness.setCurrentIndex(0) + self.channel_contact.setCurrentIndex(0) for widget in self.company_widgets.values(): widget = cast(QLineEdit, widget) widget.clear() @@ -1074,7 +1278,7 @@ class Grunderfassung_SuchWidget(CustomWidget): if ma_id is None or index == (-1): self._clear_company_fields() return - data = backend.initrec_comp_search_get_info( + data = backend.external_company_get_info( ma_id=ma_id, ) self.fill_out_company(data) @@ -1095,13 +1299,13 @@ class Grunderfassung_SuchWidget(CustomWidget): self.fill_out_person(data) @override - def reset_form(self) -> None: + def reset(self) -> None: reset_form(self.widget_registry) self._clear_company_fields() self._clear_person_fields() @override - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) return errors @@ -1124,6 +1328,18 @@ class Grunderfassung_SuchWidget(CustomWidget): set_form_data(self.widget_registry, data, filter_keys=self.DATA_EXPORT_FIELDS) + @override + def lock( + self, + ) -> None: + lock_form(self.widget_registry) + + @override + def unlock( + self, + ) -> None: + unlock_form(self.widget_registry) + def enhanced_label( base_label: str, @@ -1164,17 +1380,19 @@ def search_widgets_by_key( return hits -class AutoForm(QWidget): +class InitRecForm(QWidget): """a widget, which is managed by a code-defined field definition collection""" - update_triggered = Signal() # formular saved (data changed for front page) + # update_triggered = Signal() # formular saved (data changed for front page) def __init__( self, - cfg: AutoFormConfig, + state: Page_InitRec_Form_State, ) -> None: super().__init__() - self.cfg = cfg + self.STATE = state + self.SESSION_USER_KEY: Final[str] = "Metadaten_nutzer" + self.cfg = state.cfg self.setStyleSheet(""" QGroupBox { font-size: 16px; @@ -1212,54 +1430,76 @@ class AutoForm(QWidget): self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) - if DEBUG: - separator1 = QFrame() - separator1.setFrameShape(QFrame.Shape.HLine) - separator1.setFrameShadow(QFrame.Shadow.Sunken) - self.main_layout.addWidget(separator1) + # if DEBUG: + # separator1 = QFrame() + # separator1.setFrameShape(QFrame.Shape.HLine) + # separator1.setFrameShadow(QFrame.Shadow.Sunken) + # self.main_layout.addWidget(separator1) - self.registry_button = QPushButton("Ausgabe Widget Registry") - self.registry_button.clicked.connect(self._print_registry) - self.registry_button.setFixedHeight(35) - self.main_layout.addWidget(self.registry_button) + # self.registry_button = QPushButton("Ausgabe Widget Registry") + # self.registry_button.clicked.connect(self._print_registry) + # self.registry_button.setFixedHeight(35) + # self.main_layout.addWidget(self.registry_button) - self.test_button = QPushButton("Initialisiere Laden") - self.test_button.clicked.connect(self.load_data) - self.test_button.setFixedHeight(35) - self.test_button.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.main_layout.addWidget(self.test_button) + # # self.test_button = QPushButton("Initialisiere Laden") + # # self.test_button.clicked.connect(self.load_from_ids) + # # self.test_button.setFixedHeight(35) + # # self.test_button.setSizePolicy( + # # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # # ) + # # self.main_layout.addWidget(self.test_button) - button_get = QPushButton("GET DATA") - button_get.setFixedHeight(35) - button_get.clicked.connect(self.get_form_data) - self.main_layout.addWidget(button_get) - button_set = QPushButton("SET DATA") - button_set.setFixedHeight(35) - button_set.clicked.connect(self.set_form_data) - self.main_layout.addWidget(button_set) + # button_get = QPushButton("GET DATA") + # button_get.setFixedHeight(35) + # button_get.clicked.connect(self._get_data) + # self.main_layout.addWidget(button_get) + # button_set = QPushButton("SET DATA") + # button_set.setFixedHeight(35) + # button_set.clicked.connect(self._set_data) + # self.main_layout.addWidget(button_set) - id_field_layout = QHBoxLayout() - id_field_layout.setContentsMargins(0, 0, 0, 0) - id_field_layout.setSpacing(5) - id_field_label = QLabel("ID Datenbank:") - self.id_field_input = QLineEdit() - id_field_layout.addWidget(id_field_label) - id_field_layout.addWidget(self.id_field_input) - self.main_layout.addLayout(id_field_layout) - button_db_index = QPushButton("Setze DB Index") - button_db_index.setFixedHeight(35) - button_db_index.clicked.connect(self._set_db_index) - self.main_layout.addWidget(button_db_index) + # id_field_layout = QHBoxLayout() + # id_field_layout.setContentsMargins(0, 0, 0, 0) + # id_field_layout.setSpacing(5) + # id_field_label = QLabel("ID Datenbank:") + # self.id_field_input = QLineEdit() + # id_field_layout.addWidget(id_field_label) + # id_field_layout.addWidget(self.id_field_input) + # self.main_layout.addLayout(id_field_layout) + # button_db_index = QPushButton("Setze DB Index") + # button_db_index.setFixedHeight(35) + # button_db_index.clicked.connect(self._set_db_index) + # self.main_layout.addWidget(button_db_index) - separator2 = QFrame() - separator2.setFrameShape(QFrame.Shape.HLine) - separator2.setFrameShadow(QFrame.Shadow.Sunken) - self.main_layout.addWidget(separator2) - self.main_layout.addSpacing(5) + # separator2 = QFrame() + # separator2.setFrameShape(QFrame.Shape.HLine) + # separator2.setFrameShadow(QFrame.Shadow.Sunken) + # self.main_layout.addWidget(separator2) + # self.main_layout.addSpacing(5) - self.main_layout.addSpacing(10) + # self.main_layout.addSpacing(10) + + # buttons (save and reset) + self.add_buttons = self.cfg.add_buttons + self.edit_buttons: EditButtons | None = None + if self.add_buttons: + self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True) + modify_btn = self.edit_buttons.modify_button + assert modify_btn + self.modify_btn = modify_btn + reset_btn = self.edit_buttons.reset_btn + assert reset_btn + self.reset_btn = reset_btn + self.save_btn = self.edit_buttons.save_btn + self.delete_btn = self.edit_buttons.delete_btn + + 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_form) + # self.delete_btn.clicked.connect(self._delete_data) + + self.main_layout.addWidget(self.edit_buttons) self.top_level_form_layout = QFormLayout() self.main_layout.addLayout(self.top_level_form_layout) @@ -1274,224 +1514,765 @@ class AutoForm(QWidget): self.widget_registry, ) - # buttons (save and reset) - self.add_buttons = self.cfg.add_buttons - self.debug_form_data: dict[str, Any] = {} - if self.add_buttons: - self.layout_btn = QHBoxLayout() - self.main_layout.addLayout(self.layout_btn) - # save - self.save_btn_txt_enabled = "Speichern (Strg + S)" - self.save_btn_txt_disabled = "Wird gespeichert..." - self.save_btn = QPushButton(self.save_btn_txt_enabled) - self.save_btn.setShortcut("Ctrl+S") - self.save_btn.setFixedHeight(50) - self.save_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.save_btn.clicked.connect(self.save_data) - self.layout_btn.addWidget(self.save_btn) - # reset - self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") - self.reset_btn.setShortcut("Ctrl+Z") - self.reset_btn.setFixedHeight(50) - self.reset_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.reset_btn.clicked.connect(self.reset_form) - self.layout_btn.addWidget(self.reset_btn) - # delete - self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") - self.delete_btn.setShortcut("Ctrl+L") - self.delete_btn.setFixedHeight(50) - self.delete_btn.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.delete_btn.clicked.connect(self.delete_data) - self.layout_btn.addWidget(self.delete_btn) - self.current_id: int = -1 - - logger_auto_form.debug( - "Initialised Auto Form Widget. Registry:%s", + logger_initrecform.debug( + "[InitRecForm] Initialised Auto Form Widget. Registry:%s", pformat_registry(self.widget_registry), ) def _print_registry(self) -> None: pprint_registry(self.widget_registry) - def _set_db_index(self) -> None: - try: - index = int(self.id_field_input.text()) - except ValueError: - index = -1 - self.current_id = index - logger_gui.debug("Set index to %d", self.current_id) + # def _set_db_index(self) -> None: + # try: + # index = int(self.id_field_input.text()) + # except ValueError: + # index = None + # self.STATE.ids.un_id = index + # self.STATE.ids.pers_id = index + # logger_gui.debug("[InitRecForm] Set index to %s, new state: %s", index, self.STATE) - def _disable_save(self) -> None: - self.save_btn.setEnabled(False) - self.save_btn.setText(self.save_btn_txt_disabled) + # def _disable_save(self) -> None: + # assert self.save_btn is not None + # assert self.edit_buttons is not None + # self.save_btn.setEnabled(False) + # self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) - def _enable_save( - self, - timeout: int = 3000, - ) -> None: - QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) - QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) - self.save_btn.setText(self.save_btn_txt_enabled) + # def _enable_save( + # self, + # timeout: int = 3000, + # ) -> None: + # assert self.save_btn is not None + # assert self.edit_buttons is not None + # QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + # QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + # self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) - def _activation_delete(self) -> None: - if self.current_id > -1: - self.delete_btn.setEnabled(True) - else: - self.delete_btn.setEnabled(False) + def relevant_ids_set(self) -> bool: + return self.STATE.ids.valid(self.STATE.ent_type) - def delete_data(self) -> None: - assert self.current_id > -1, "deletion initialised despite no index set" - self.cfg.data_delete(self.current_id) - self.update_triggered.emit() - self.reset_form() + # def _activate_delete(self) -> None: + # if self.STATE.ids.valid(): + # self.delete_btn.setEnabled(True) + # else: + # self.delete_btn.setEnabled(False) - def load_data( - self, - lookup_id: int | None = None, - ) -> None: - if DEBUG_NO_DATABASE: - return + # def _delete_data(self) -> None: + # assert self.STATE.ids.valid(), "deletion initialised despite IDs are not properly set" + # confirm = get_user_confirmation( + # self, + # "Löschen bestätigen", + # "Soll der Eintrag wirklich gelöscht werden?", + # ) + # if not confirm: + # return - logger_auto_form.info(">>>> LOAD CLICKED") - if lookup_id is None or lookup_id == 0: - lookup_id = self.current_id - self.reset_form() + # self.STATE.geloescht = True + # self.save_data() + # self.update_triggered.emit() - logger_auto_form.debug("Lookup ID: %d", lookup_id) + # !! must be part of the managing page + # def load_from_ids( + # self, + # ids: NewEntityIds, + # ) -> None: + # if DEBUG_NO_DATABASE: + # return - if lookup_id > 0: - logger_auto_form.debug("Load from DB:") - loaded_data = self.cfg.data_get(lookup_id) - else: - logger_auto_form.error("Loading: Lookup ID <= 0. Do nothing!") - return + # logger_initrecform.info("[InitRecForm] Load data method...") + # logger_initrecform.debug("[InitRecForm] Lookup ID: %s", ids) + # self.reset_form() - logger_auto_form.debug( - "Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + # if ids.empty(self.STATE.recording_type): + # logger_initrecform.debug("[InitRecForm] Loading: Lookup ID NULL. Do nothing!") + # return + # logger_initrecform.debug("Load from DB:") + # from_db = self.cfg.from_db(ids) + + # # TODO add path to avoid unnecessary loading (where the same ID is loaded + # # twice in a row) -- use an internal marker (not state-related) + + # # logger_auto_form.debug( + # # "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) + # # ) + # logger_initrecform.debug( + # "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", + # pformat(from_db.db_data), + # ) + # model = self.cfg.pydantic_model(**from_db.db_data) + # logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") + # logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") + # form_data = model.to_gui() + # logger_initrecform.debug("[InitRecForm] Set form data...") + # logger_initrecform.debug("[InitRecForm] Form data:\n%s", pformat(form_data)) + # self._set_data(form_data) + # # self.STATE.ids.set_id(id_, self.STATE.ent_type) + # self.STATE.ids.update(from_db.ids) + # self.STATE.form_data = self._get_data() + # logger_initrecform.debug( + # "[InitRecForm] Model dump vs. get form data:\n%s\n\n%s", + # pformat(model.model_dump(mode="python")), + # pformat(self.STATE.form_data), + # ) + + def _load_state_form_data(self) -> None: + logger_initrecform.info("[InitRecForm] Load data method...") + logger_initrecform.debug( + "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", + pformat(self.STATE.form_data), ) - model = Grunderfassung(**loaded_data) - logger_auto_form.debug("Loaded to Pydantic.") - logger_auto_form.debug("Convert to GUI structure...") - form_data = model.to_gui() - logger_auto_form.debug("Set form data...") - logger_auto_form.debug("Form data:\n%s", pformat(form_data)) - self.set_form_data(form_data) - self.current_id = lookup_id - self._activation_delete() - - def save_data(self) -> None: - self._disable_save() - errors = self.validate_form_data() - - if errors: - # errors: abort saving and show pop up window - error_text = ( - "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " - + "\n▸ ".join(errors) - ) - QMessageBox.warning(self, "Fehlende Angaben", error_text) - self._enable_save() - return - - # no errors: data can be saved - logger_auto_form.info("Erfolg! Alle Daten sind valide.") - logger_auto_form.info("Get form data call...") - form_data = self.get_form_data() - - logger_auto_form.debug( - "\n\n------------>>>>>>>>> Get form data\n%s", pformat(form_data) + # TODO error catching? + logger_initrecform.info( + "[InitRecForm] Pydantic model name: %s", + self.STATE.cfg.pydantic_model.__name__, ) - try: - logger_auto_form.debug("------------>>>>>>>>> Call Pydantic") - validated_data = self.cfg.model(**form_data) - # # TODO add user? - except ValidationError as e: - # catch errors and show them in GUI - error_texts = [] - - # Pydantic detailed error list - for error in e.errors(): - logger_gui.error("Error during validation phase:\n%s", pformat(error)) - - error_field = str(error["loc"][0]) - reason = error["msg"] - - error_texts.append(f"- {error_field}: {reason}") - - # TODO: Is this needed and feasible? - # formatting red - # if fehlerhaftes_feld in self.widgets: - # self.widgets[fehlerhaftes_feld].setStyleSheet( - # "border: 1px solid red; background: #fef2f2;" - # ) - - # tell user what went wrong - QMessageBox.warning(self, "Eingabefehler", "\n".join(error_texts)) - else: - if DEBUG_NO_DATABASE: - return - # !! this code is only called if the 'try' block was successful - # save data to database - db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) - logger_auto_form.debug( - ("Form data with 'exlude' (must be saved in the database):\n%s"), - pformat(db_data), - ) - - if self.current_id < 0: - logger_auto_form.debug("Insert triggered") - init_rec_id = self.cfg.data_insert(db_data) - assert isinstance(init_rec_id, int) - self.current_id = init_rec_id - else: - logger_auto_form.debug("Update triggered") - self.cfg.data_update(self.current_id, db_data) - - logger_auto_form.info("Data saved successfully") - self.update_triggered.emit() - self._activation_delete() - # self.reset_form() # TODO check if this behaviour is expected - finally: - # always re-enable save, even if error occurred - self._enable_save() - - def validate_form_data(self) -> list[str]: - return validate_form_data(self.widget_registry) + model = self.cfg.pydantic_model(**self.STATE.form_data) + logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.") + logger_initrecform.debug("[InitRecForm] Convert to GUI structure...") + form_data_gui = model.to_gui() + self._set_data(form_data_gui) def reset_form(self) -> None: reset_form(self.widget_registry) - self.current_id = -1 - self._activation_delete() + self.STATE.ids.update(EntityIds()) + self.STATE.form_data.clear() + self.STATE.geloescht = False - def get_form_data(self) -> dict[str, Any]: + 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_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call get form data:") - logger_auto_form.debug("Form Data:\n%s", pformat(form_data)) + logger_initrecform.debug("[InitRecForm] Call get form data:") + logger_initrecform.debug("[InitRecForm] Form Data:\n%s", pformat(form_data)) if DEBUG_GET_SET: self.debug_form_data = form_data return form_data - def set_form_data( + def _set_data( self, data: dict[str, Any], ) -> None: - logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call set form data:") + logger_initrecform.debug("[InitRecForm] Call set form data...") if DEBUG_GET_SET: data = self.debug_form_data set_form_data(self.widget_registry, data) + def _sync_state_to_GUI( + self, + section: Literal["form", "locking"] | None, + ) -> None: + # TODO this should be initialised by states analogous to consulting sessions + # !! loading from IDs is only performed on top-level modules aka pages + if section is None or section == "form": + if not self.STATE.geloescht: + self._load_state_form_data() + else: + self.reset_form() + + if section is None or section == "locking": + if self.STATE.locked: + if self.edit_buttons is not None: + self.edit_buttons.set_modify_state(False, set_delete_btn=True) + lock_form(self.widget_registry) + else: + if self.edit_buttons is not None: + self.edit_buttons.set_modify_state(True, set_delete_btn=False) + unlock_form(self.widget_registry) + # self._activate_delete() + + def _sync_GUI_to_state(self) -> None: + form_data = self._get_data() + # TODO change usage of user ID (needs changes in model and backend) + if self.SESSION_USER_KEY in form_data: + form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name + + for form_key, id_key in self.STATE.cfg.id_mapping.items(): + self.STATE.ids.set_by_name(id_key, form_data[form_key]) + + self.STATE.form_data = form_data + + def validate(self) -> list[str]: + return validate_form_data(self.widget_registry) + + def load_state( + self, + new_state: Page_InitRec_Form_State, + ) -> None: + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI(None) + + def get_state(self) -> Page_InitRec_Form_State: + self._sync_GUI_to_state() + + return self.STATE + + # def _load_from_pydantic( + # self, + # data: InitRec, + # ) -> None: + # # ent_type = self.STATE.recording_type + # # self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type) + # self.STATE.ids.update(data.ids) + # # self.STATE.rec_id = data.rec_id + # self.STATE.form_data.clear() + # self.STATE.geloescht = True if data.geloescht else False + + # self._sync_state_to_GUI(None) # TODO change to correct loading + + # !! must be part of the managing page + def save_data(self) -> None: + pass + + # def save_data(self) -> None: + # if self.STATE.ids.empty() and self.STATE.geloescht: + # logger_initrecform.debug( + # "[InitRecForm] This was never saved in the database " + # "and is marked for deletion - ignore." + # ) + # return + # elif not self.STATE.ids.empty() and self.STATE.geloescht: + # logger_initrecform.debug( + # "[InitRecForm] This was saved in the database " + # "and is marked for deletion - fast path." + # ) + # deleted_datetime = datetime.datetime.now(datetime.UTC) + # assert self.STATE.form_data, "cannot delete entry with uninitialised form data" + # to_db = InitRec( + # ids=self.STATE.ids, + # geloescht=deleted_datetime, + # db_data={}, + # ) + # returned = self.cfg.to_db(to_db) + # self._load_from_pydantic(returned) + # msg_box = get_message_box( + # QMessageBox.Icon.Information, + # "Löschen erfolgreich", + # "Der Eintrag wurde erfolgreich gelöscht", + # ) + # msg_box.exec() + + # return + + # self._disable_save() + # errors = self.validate() + + # if errors: + # logger_initrecform.error( + # "[InitRecForm] Error during GUI validation phase:\n%s", pformat(errors) + # ) + # GUI_pydantic_validation_error_handling(errors) + # self._enable_save() + # return + + # logger_initrecform.info("[InitRecForm] Success! All data is valid") + # logger_initrecform.info("[InitRecForm] Get form data call...") + # state = self.get_state() + # form_data = state.form_data + # assert form_data, "form data not set" + + # logger_initrecform.debug("[InitRecForm] Get form data\n%s", pformat(form_data)) + + # try: + # logger_initrecform.debug("[InitRecForm] Call Pydantic...") + # validated_data = self.cfg.pydantic_model(**form_data) + # except ValidationError as e: + # logger_initrecform.error( + # "[InitRecForm] Error during Pydantic validation phase:\n%s", + # pformat(e.errors()), + # ) + # pydantic_validation_error_handling(e) + # else: + # if DEBUG_NO_DATABASE: + # return + # # !! this code is only called if the 'try' block was successful + # # save data to database + # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + # logger_initrecform.debug( + # "[InitRecForm] 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 + # to_db = InitRec( + # ids=self.STATE.ids, + # # rec_id=self.STATE.rec_id, + # geloescht=deleted_datetime, + # db_data=db_data, + # ) + # returned = self.cfg.to_db(to_db) + + # self._load_from_pydantic(returned) + # self.update_triggered.emit() + # except Exception as err: + # msg_box = get_message_box( + # QMessageBox.Icon.Warning, + # "Speichern fehlgeschlagen", + # ( + # "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + # "unten angefügt." + # ), + # detailed_text=str(err), + # ) + # msg_box.exec() + # else: + # logger_initrecform.info("[InitRecForm] Data saved successfully") + # msg_box = get_message_box( + # QMessageBox.Icon.Information, + # "Speichern erfolgreich", + # "Die Daten wurden erfolgreich gespeichert", + # ) + # msg_box.exec() + + # finally: + # # always re-enable save, even if error occurred + # self._enable_save() + + +class FlexibleDateInput(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._read_only: bool = False + self._required: bool = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # simple text field allows for easy editing and deletion + self.line_edit = QLineEdit() + self.line_edit.setPlaceholderText("TT.MM.JJJJ") + self.line_edit.setInputMask("99.99.9999;_") # force date structure + + self.button = QPushButton("📅") + self.button.setFixedWidth(30) + + self.line_edit.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + layout.addWidget(self.line_edit) + layout.addWidget(self.button) + + self.calendar = QCalendarWidget() + self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window + self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + self.calendar.setGridVisible(True) + self.calendar.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + + self.button.clicked.connect(self.show_calendar) + self.calendar.activated.connect(self.set_date) + # signal to validate + self.line_edit.editingFinished.connect(self._validate_input) + self.line_edit.installEventFilter(self) + + self.setReadOnly(False) + + def eventFilter( + self, + watched: QObject, + event: QEvent, + ): + # press enter: direct validation + if watched == self.line_edit: + if event.type() == QEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore + self._validate_input() + return True # mark event as processed + elif event.type() == QEvent.Type.FocusOut: + self._validate_input() + # no return to not block focus change + return super().eventFilter(watched, event) + + def show_calendar(self) -> None: + if self._read_only: + return + pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft()) + self.calendar.move(pos) + self.calendar.show() + + def isRequired(self) -> bool: + return self._required + + def setRequired( + self, + required: bool, + ) -> None: + self._required = required + + def isReadOnly(self) -> bool: + return self._read_only + + def setReadOnly( + self, + read_only: bool, + style_class_readonly: str = DEFAULT_READONLY_STYLECLASS, + ): + self._read_only = read_only + self.line_edit.setReadOnly(read_only) + self.button.setEnabled(not read_only) + + current_text = self.line_edit.text() + + if read_only: + self.line_edit.setInputMask("") + self.line_edit.setPlaceholderText("kein Datum eingetragen") + pure = ( + current_text.replace(".", "") + .replace(":", "") + .replace(" ", "") + .replace("_", "") + .strip() + ) + cleaned_date = current_text if pure else "" + self.line_edit.setText(cleaned_date) + self.line_edit.setProperty("styleClass", style_class_readonly) + else: + self.line_edit.setInputMask("99.99.9999;_") + self.line_edit.setPlaceholderText("TT.MM.JJJJ") + self.line_edit.setProperty("styleClass", "") + + self.line_edit.style().unpolish(self.line_edit) + self.line_edit.style().polish(self.line_edit) + self.line_edit.update() + + def _validate_input( + self, + ) -> None: + logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation") + if self._read_only: + return + + focused_widget = QApplication.focusWidget() + if focused_widget == self.button or focused_widget == self.calendar: + return + + text = self.line_edit.text() + # field empty + pure_text = text.replace(".", "").replace("_", "").strip() + if not self._required and not pure_text: + self.line_edit.setStyleSheet("") + return + + # if user wrote input + date = QDate.fromString(text, "dd.MM.yyyy") + + if not date.isValid(): + self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;") + self.line_edit.selectAll() + # optional: keep focus in field to directly correct entry + # self.line_edit.setFocus() + else: + self.line_edit.setStyleSheet("") + + def validate( + self, + ) -> bool: + logger_custom_fields.debug("[CustomField - FlexDate]: Call validate...") + self._validate_input() + date = self.get_date() + if date and date.isValid(): + logger_custom_fields.debug("[CustomField - FlexDate]: returning valid") + return True + logger_custom_fields.debug("[CustomField - FlexDate]: returning invalid") + return False + + def set_date( + self, + date: QDate, + ) -> None: + if self._read_only: + return + + if date and date.isValid(): + self.line_edit.setText(date.toString("dd.MM.yyyy")) + else: + self.line_edit.clear() + + self.line_edit.setText(date.toString("dd.MM.yyyy")) + self.calendar.hide() + + def set_pydate( + self, + date: datetime.datetime | datetime.date | None, + raise_error: bool = False, + ) -> None: + if date is None: + self.reset() + return + + date_to_set = QDate(date.year, date.month, date.day) + if raise_error and not date_to_set.isValid(): + raise ValueError("Could not parse date object correctly") + + self.set_date(date_to_set) + + def get_date(self) -> QDate: + # valid or invalid QDate + return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy") + + def get_pydate(self) -> datetime.date | None: + # valid or invalid QDate + date = self.get_date() + if not date.isValid(): + return None + py_date = cast(datetime.date, date.toPython()) + + return py_date + + def reset(self) -> None: + self.calendar.hide() + self.line_edit.clear() + + +class FlexibleDateTimeInput(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._read_only: bool = False + self._required: bool = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Simple text field allows for easy editing and deletion (with Time) + self.line_edit = QLineEdit() + self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") + self.line_edit.setInputMask("99.99.9999 99:99;_") # force datetime structure + + self.button = QPushButton("📅") + self.button.setFixedWidth(30) + + self.line_edit.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + layout.addWidget(self.line_edit) + layout.addWidget(self.button) + + self.calendar = QCalendarWidget() + self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window + self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + self.calendar.setGridVisible(True) + self.calendar.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + + self.button.clicked.connect(self.show_calendar) + self.calendar.activated.connect(self.set_date_from_calendar) + + # signal to validate + self.line_edit.editingFinished.connect(self._validate_input) + self.line_edit.installEventFilter(self) + + self.setReadOnly(False) + + def eventFilter( + self, + watched: QObject, + event: QEvent, + ): + # press enter: direct validation + if watched == self.line_edit: + if event.type() == QEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore + self._validate_input() + return True # mark event as processed + elif event.type() == QEvent.Type.FocusOut: + self._validate_input() + # no return to not block focus change + return super().eventFilter(watched, event) + + def show_calendar(self) -> None: + if self._read_only: + return + pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft()) + self.calendar.move(pos) + self.calendar.show() + + def isRequired(self) -> bool: + return self._required + + def setRequired( + self, + required: bool, + ) -> None: + self._required = required + + def isReadOnly(self) -> bool: + return self._read_only + + def setReadOnly( + self, + read_only: bool, + style_class_readonly: str = DEFAULT_READONLY_STYLECLASS, + ): + self._read_only = read_only + self.line_edit.setReadOnly(read_only) + self.button.setEnabled(not read_only) + + current_text = self.line_edit.text() + + if read_only: + self.line_edit.setInputMask("") + self.line_edit.setPlaceholderText("kein Zeitstempel eingetragen") + pure = ( + current_text.replace(".", "") + .replace(":", "") + .replace(" ", "") + .replace("_", "") + .strip() + ) + cleaned_datetime = current_text if pure else "" + self.line_edit.setText(cleaned_datetime) + self.line_edit.setProperty("styleClass", style_class_readonly) + else: + self.line_edit.setInputMask("99.99.9999 99:99;_") + self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") + self.line_edit.setProperty("styleClass", "") + + self.line_edit.style().unpolish(self.line_edit) + self.line_edit.style().polish(self.line_edit) + self.line_edit.update() + + def _validate_input( + self, + ) -> None: + logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation") + if self._read_only: + return + + focused_widget = QApplication.focusWidget() + if focused_widget == self.button or focused_widget == self.calendar: + return + + text = self.line_edit.text() + # field empty (ignoring dots, colons, spaces, underscores) + pure_text = ( + text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip() + ) + if not self._required and not pure_text: + self.line_edit.setStyleSheet("") + return + + # check logic for QDateTime + dt = QDateTime.fromString(text, "dd.MM.yyyy hh:mm") + + if not dt.isValid(): + self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;") + self.line_edit.selectAll() + else: + self.line_edit.setStyleSheet("") + + def validate( + self, + ) -> bool: + logger_custom_fields.debug("[CustomField - FlexDatetime]: Call validate...") + self._validate_input() + datetime = self.get_datetime() + if datetime and datetime.isValid(): + logger_custom_fields.debug("[CustomField - FlexDatetime]: returning valid") + return True + logger_custom_fields.debug("[CustomField - FlexDatetime]: returning invalid") + return False + + def set_date_from_calendar( + self, + date: QDate, + ) -> None: + if self._read_only: + return + + # save time user already provided it + current_text = self.line_edit.text() + time_part = "00:00" + + # extract time portion (last 5 characters of "dd.MM.yyyy hh:mm") + if len(current_text) >= 16: + potential_time = current_text[11:16] + if "_" not in potential_time: # only if time was fully provided + time_part = potential_time + + if date and date.isValid(): + formatted_date = date.toString("dd.MM.yyyy") + self.line_edit.setText(f"{formatted_date} {time_part}") + self.line_edit.setStyleSheet("") + else: + self.line_edit.clear() + + self.calendar.hide() + + def set_datetime( + self, + dt: QDateTime, + ) -> None: + if dt and dt.isValid(): + self.line_edit.setText(dt.toString("dd.MM.yyyy hh:mm")) + self.line_edit.setStyleSheet("") + else: + self.line_edit.clear() + + def set_pydatetime( + self, + dt: datetime.datetime | None, + raise_error: bool = False, + ) -> None: + if dt is None: + self.reset() + return + + dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0) + if raise_error and not dt_to_set.isValid(): + raise ValueError("Could not parse datetime object correctly") + + self.set_datetime(dt_to_set) + + def get_datetime(self) -> QDateTime: + # valid or invalid QDateTime + return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm") + + def get_pydatetime(self) -> datetime.datetime | None: + """returns current value of the field as UTC datetime + + Returns + ------- + datetime.datetime + UTC datetime object + """ + # valid or invalid datetime + dt = self.get_datetime() + if not dt.isValid(): + return None + py_dt = cast(datetime.datetime, dt.toPython()) + py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST) + + return py_dt.astimezone(datetime.UTC) + + def reset(self) -> None: + self.calendar.hide() + self.line_edit.clear() + class DynamicListWidget(QWidget): """ @@ -1524,9 +2305,10 @@ class DynamicListWidget(QWidget): self.sub_forms: list[SubForm] = [] # button to add more sub forms self.add_btn = QPushButton("+ Hinzufügen") - self.add_btn.setStyleSheet( - "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" - ) + self.add_btn.setProperty("styleClass", "outlineAddButton") + # self.add_btn.setStyleSheet( + # "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" + # ) self.add_btn.clicked.connect(self.add_entry) self.inner_layout.addWidget(self.add_btn) @@ -1581,14 +2363,14 @@ class DynamicListWidget(QWidget): base_label=self.base_label, ) - def reset_form(self) -> None: + def reset(self) -> None: while self.sub_forms: self.remove_entry(self.sub_forms[0]) if self.add_empty_entry: self.add_entry() - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -1620,6 +2402,14 @@ class DynamicListWidget(QWidget): current_sub_form = self.sub_forms[-1] set_form_data(current_sub_form.registry, sub_form_data) + def lock(self) -> None: + lock_form(self.widget_registry) + self.add_btn.setEnabled(False) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + self.add_btn.setEnabled(True) + class DynamicDropdownWidgetNumeric(QWidget): """ @@ -1739,11 +2529,11 @@ class DynamicDropdownWidgetNumeric(QWidget): sub_forms=self.sub_forms, ) - def reset_form(self) -> None: + def reset(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -1763,8 +2553,9 @@ class DynamicDropdownWidgetNumeric(QWidget): def set_form_data( self, - sub_form_data: dict[str, Any], + data: dict[str, Any], ) -> None: + # data is data of sub forms # delete all rows while self.sub_forms: self._remove_row() @@ -1774,23 +2565,23 @@ class DynamicDropdownWidgetNumeric(QWidget): num_subforms: int = -1 for key in self.full_keys: widget = self.widget_registry[key]["widget"] - value = sub_form_data[key] + value = data[key] set_widget_value(widget, value) if value is None: num_subforms = 0 else: num_subforms = value - del sub_form_data[key] + del data[key] logger_get_data.debug(">>>>>>>>> Call set_form_data for DynamicDropdown") - logger_get_data.debug("Data before unmerge:%s", pformat(sub_form_data)) + logger_get_data.debug("Data before unmerge:%s", pformat(data)) assert len(self.sub_forms) == num_subforms if not self.sub_forms: return relevant_keys = self.sub_forms[-1].full_keys # all keys of sub forms are the same - sub_data = {key: sub_form_data[key] for key in relevant_keys} + sub_data = {key: data[key] for key in relevant_keys} logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) if not sub_data: return @@ -1799,8 +2590,18 @@ class DynamicDropdownWidgetNumeric(QWidget): logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) assert len(sub_data) == len(self.sub_forms) - for sub_form_data, sub_form in zip(sub_data, self.sub_forms): - set_form_data(sub_form.registry, sub_form_data) + for data, sub_form in zip(sub_data, self.sub_forms): + set_form_data(sub_form.registry, data) + + def lock(self) -> None: + lock_form(self.widget_registry) + for sub_form in self.sub_forms: + lock_form(sub_form.registry) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + for sub_form in self.sub_forms: + unlock_form(sub_form.registry) class DynamicDropdownWidgetOption(QWidget): @@ -1896,7 +2697,6 @@ class DynamicDropdownWidgetOption(QWidget): sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form) form_field_def = copy.copy(self.assigned_form_fields) - # form_field_def.label = form_field_def.enhanced_label(f"{number_form}") sub_form.full_keys = _build_ui_recursively( schema=form_field_def, @@ -1932,11 +2732,11 @@ class DynamicDropdownWidgetOption(QWidget): return whole_registry - def reset_form(self) -> None: + def reset(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) - def validate_form_data(self) -> list[str]: + def validate(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) @@ -1991,6 +2791,16 @@ class DynamicDropdownWidgetOption(QWidget): set_form_data(whole_registry, data) + def lock(self) -> None: + lock_form(self.widget_registry) + for sub_form in self.sub_forms: + lock_form(sub_form.registry) + + def unlock(self) -> None: + unlock_form(self.widget_registry) + for sub_form in self.sub_forms: + unlock_form(sub_form.registry) + class NoScrollFilter(QObject): """disables scrolling in fields""" @@ -2010,18 +2820,36 @@ class NoScrollFilter(QObject): return super().eventFilter(obj, event) -class ClickableCell(QFrame): - """cell in the table on the startup screen""" - - clicked = Signal(backend.FrontpageCompany) +class HeaderCell(QLabel): + """basic header for table like objects""" + + def __init__( + self, + text: str, + font_size: int = 12, + ): + super().__init__(text) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setStyleSheet(f""" + HeaderCell {{ + background-color: #e2e8f0; + color: #475569; + font-weight: bold; + padding: 10px; + border-radius: 6px; + font-size: {font_size}px; + }} + """) + + +class ClickableCell(QFrame): + """basic cell which can be clicked""" def __init__( self, text: str, - data_record: backend.FrontpageCompany, ): super().__init__() - self.data_record = data_record self.setStyleSheet(""" ClickableCell { background-color: white; @@ -2039,109 +2867,306 @@ class ClickableCell(QFrame): label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) + +class CompanyProfile_Consultation_ClickableCell(ClickableCell): + """cell on the company profile for consultations""" + + clicked = Signal(CompanyProfileConsultationEntry) + + def __init__( + self, + text: str, + data_record: CompanyProfileConsultationEntry, + ): + super().__init__(text=text) + self.data_record = data_record + def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data_record) -class HeaderCell(QLabel): - def __init__(self, text): - super().__init__(text) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setStyleSheet(""" - HeaderCell { - background-color: #e2e8f0; - color: #475569; - font-weight: bold; - padding: 10px; - border-radius: 6px; - } - """) +class MainPage_ClickableCell(ClickableCell): + """cell in the table on the startup screen""" + + clicked = Signal(types.MainPageEntry) + + def __init__( + self, + text: str, + data_record: types.MainPageEntry, + ): + super().__init__(text=text) + self.data_record = data_record + + logger_gui.debug( + "Initialised ClickableCell on MainPage with data record:\n%s", + pformat(self.data_record), + ) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.data_record) -class NewEntrySelect_view(QWidget): +class Page_NewEntry(QWidget): """view for new entry for initial recording ("Grunderfassung")""" back_requested = Signal() # Signal back button - company_requested = Signal() # Signal "Unternehmen" - person_requested = Signal() # Signal "Individualperson" + # TODO re-enable with correct settings + # company_requested = Signal(Page_InitRec_State) # Signal "Unternehmen" + # person_requested = Signal(Page_InitRec_State) # Signal "Individualperson" + new_consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" + new_initrec_requested = Signal(Page_InitRec_State) # TODO: rename - def __init__(self): + def __init__( + self, + state: Page_NewEntry_State, + ): super().__init__() - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + self.STATE = state + + outer_layout = QHBoxLayout(self) + layout_container = QWidget() + layout_container.setMinimumWidth(700) + layout_container.setMaximumWidth(1000) + layout = QVBoxLayout(layout_container) + + outer_layout.addStretch(1) + outer_layout.addWidget(layout_container, stretch=100) + # outer_layout.addWidget(scroll_area, stretch=100) + outer_layout.addStretch(1) + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Zurück-Button back_btn = QPushButton("← Zurück zur Übersicht") back_btn.setFixedWidth(200) back_btn.clicked.connect(lambda: self.back_requested.emit()) - # Platzhalter für die Details self.title_label = QLabel("Wählen Sie den Typ der Grunderfassung") self.title_label.setStyleSheet( "font-size: 24px; font-weight: bold; margin-top: 20px;" ) + title_initrec = QLabel("Grunderfassung anlegen") + title_initrec.setStyleSheet( + "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" + ) btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) - btn_company.clicked.connect(lambda: self.company_requested.emit()) - + btn_company.clicked.connect(self._request_initrec_company) btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) - btn_person.clicked.connect(lambda: self.person_requested.emit()) - # btn_person.clicked.connect( - # lambda: logger_gui.info("[Grunderfassung: Dummy Individualperson] Person gewählt") - # ) + btn_person.clicked.connect(self._request_initrec_person) + + title_consulting = QLabel("Beratung durchführen") + title_consulting.setStyleSheet( + "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" + ) + btn_consulting = QPushButton("Beratungsgespräch →") + btn_consulting.setFixedWidth(300) + btn_consulting.setFixedHeight(40) + btn_consulting.clicked.connect(self._request_consulting) + + # TODO check removal + # btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →") + # btn_newinitrec.setFixedWidth(300) + # btn_newinitrec.setFixedHeight(40) + # btn_newinitrec.clicked.connect(self._debug_request_new_initrec) layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) + # layout.addSpacing(20) + # layout.addWidget(btn_newinitrec) + # layout.addSpacing(20) + layout.addWidget(title_initrec) layout.addWidget(btn_company) layout.addWidget(btn_person) + layout.addSpacing(10) + layout.addWidget(title_consulting) + layout.addWidget(btn_consulting) + + # TODO configure correctly + def _request_initrec_company(self) -> None: + logger_gui.debug("[Page -- NewEntry] Call for company...") + logger_gui.debug("[Page -- NewEntry] Call for person...") + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) + + def _request_initrec_person(self) -> None: + logger_gui.debug("[Page -- NewEntry] Call for person...") + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITHOUT_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) + + def _debug_request_new_initrec(self) -> None: + req_state = Page_InitRec_State( + session=self.STATE.session, + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) + self.new_initrec_requested.emit(req_state) + + def _request_consulting(self) -> None: + req_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=None, + beratungs_typ=ConsultingType.PAUSCHAL, + locked=False, + ) + logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) + self.new_consulting_requested.emit(req_state) + + def _sync_state_to_GUI(self) -> None: + pass + + def _sync_GUI_to_state(self) -> None: + pass + + def validate(self) -> None: + pass + + def load_state( + self, + new_state: Page_NewEntry_State, + ) -> None: + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI() + + def get_state(self) -> Page_NewEntry_State: + self._sync_GUI_to_state() + return self.STATE + + def save_data(self) -> None: + pass -CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( - model=Grunderfassung, - data_insert=backend.initrec_insert_initial_recording, - data_update=backend.initrec_update_initial_recording, - data_get=backend.initrec_get_initial_recording, - data_delete=backend.initrec_delete_initial_recording, - ignored_keys=( - "Metadaten_erstellung", - "Metadaten_aktualisierung", - "Metadaten_wiedereintrittsdatum", - ), - form_fields=INITREC_COMP, -) +# CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( +# pydantic_model=Grunderfassung_Unternehmen, +# # to_db=backend.initrec_company_to_db, # TODO remove +# # from_db=backend.initrec_company_from_db, # TODO remove +# ignored_keys=( +# "Metadaten_erstellung", +# "Metadaten_aktualisierung", +# ), +# form_fields=INITREC_COMP, +# add_buttons=False, +# ) -CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( - model=Grunderfassung, - data_insert=backend.initrec_insert_initial_recording, - data_update=backend.initrec_update_initial_recording, - data_get=backend.initrec_get_initial_recording, - data_delete=backend.initrec_delete_initial_recording, - ignored_keys=( - "Metadaten_erstellung", - "Metadaten_aktualisierung", - "Partnersuche", - ), - form_fields=INITREC_PERSON, -) +# CONFIG_GRUNDERFASSUNG_PERSONEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config( +# pydantic_model=Grunderfassung_Personen, +# # to_db=backend.initrec_person_to_db, # TODO remove +# # from_db=backend.initrec_person_from_db, # TODO remove +# ignored_keys=( +# "Metadaten_erstellung", +# "Metadaten_aktualisierung", +# ), +# form_fields=INITREC_PERSON, +# add_buttons=False, +# ) -CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { - "grunderfassung_suche": Grunderfassung_SuchWidget, +CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = { + "grunderfassung_unternehmen_suche": Grunderfassung_SuchWidget, } -class Page_InitRecCompany(QWidget): +class CollapsibleBox(QWidget): + def __init__( + self, + title: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + # 1. Header-Button mit Pfeil + self.toggle_button = QToolButton() + 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) + self.toggle_button.setArrowType(Qt.ArrowType.RightArrow) + self.toggle_button.setStyleSheet( + "QToolButton { border: none; font-weight: bold; font-size: 13px; }" + ) + self.toggle_button.toggled.connect(self._on_toggle) + + # 2. Inhaltsbereich (standardmäßig ausgeblendet) + self.content_area = QWidget() + self.content_area.setVisible(False) + + # Layout des Box-Containers + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 5, 0, 5) + main_layout.addWidget(self.toggle_button) + main_layout.addWidget(self.content_area) + + 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 + ) + self.content_area.setVisible(checked) + + 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, + ) -> None: + self._on_toggle(open) + + +class Page_InitRec(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) + company_profile_requested = Signal(Page_CompanyProfile_State) - def __init__(self): + def __init__( + self, + state: Page_InitRec_State, + ): super().__init__() + self.STATE = state # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2172,15 +3197,92 @@ class Page_InitRecCompany(QWidget): back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) - title = QLabel("Grunderfassung Unternehmen") + if self.STATE.recording_type is RecordingType.WITH_COMPANY: + title = QLabel("Grunderfassung Unternehmen") + elif self.STATE.recording_type is RecordingType.WITHOUT_COMPANY: + title = QLabel("Grunderfassung Individualperson") + else: + raise RuntimeError(f"Unspecified recording type {self.STATE.recording_type}") title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) + + self.comp_profile_btn = QPushButton("Unternehmensprofil") + self.comp_profile_btn.clicked.connect(self._request_company_profile) + self.comp_profile_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.comp_profile_btn.setFixedHeight(50) + header_layout.addWidget(self.comp_profile_btn) + vert_layout.addWidget(header_container) + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + header_layout.addWidget(separator1) + + btn_gui2state = QPushButton("Sync GUI to state") + btn_gui2state.clicked.connect(self._sync_GUI_to_state) + btn_gui2state.setFixedHeight(35) + btn_gui2state.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_gui2state) + + btn_state2gui = QPushButton("Sync state to GUI") + btn_state2gui.clicked.connect(self._sync_state_to_GUI) + btn_state2gui.setFixedHeight(35) + btn_state2gui.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_state2gui) + + btn_print_state = QPushButton("Print State") + btn_print_state.clicked.connect(self._debug_print_state) + btn_print_state.setFixedHeight(35) + btn_print_state.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + header_layout.addWidget(btn_print_state) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + header_layout.addWidget(separator2) + header_layout.addSpacing(5) + + # --- BANNER --- + self.info_banners_container = QWidget() + self.info_banners: list[InfoBanner] = [] + self.info_banners_layout = QVBoxLayout(self.info_banners_container) + self.info_banners_layout.setContentsMargins(0, 0, 0, 0) + self.info_banners_layout.setSpacing(6) + header_layout.addWidget(self.info_banners_container) + + # --- BUTTONS --- + self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True) + modify_btn = self.edit_buttons.modify_button + assert modify_btn + self.modify_btn = modify_btn + reset_btn = self.edit_buttons.reset_btn + assert reset_btn + self.reset_btn = reset_btn + self.save_btn = self.edit_buttons.save_btn + self.delete_btn = self.edit_buttons.delete_btn + + 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._trigger_reset_btn) + self.delete_btn.clicked.connect(self._delete_data) + header_layout.addSpacing(5) + header_layout.addWidget(self.edit_buttons) + # --- MAIN CONTENT --- container = QWidget() # SCROLL AREA @@ -2195,55 +3297,155 @@ class Page_InitRecCompany(QWidget): vert_layout.addWidget(scroll_area) - container_layout = QVBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) + self.container_layout = QVBoxLayout(container) + self.container_layout.setContentsMargins(0, 0, 0, 0) # --- AUTO FORM LAYOUT --- - container_layout.addSpacing(20) - self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_UNTERNEHMEN) - container_layout.addWidget(self.auto_form) - self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) + # now we try to render the layout within the collapsable box + self.container_layout.addSpacing(20) - container_layout.addSpacing(15) + # ** metadata + self.initrec_form_metadata_box = CollapsibleBox("Metadaten") + ent_type = EntityType.METADATA + self.initrec_form_metadata = InitRecForm(self.STATE.initrec_form_states[ent_type]) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_metadata.STATE + + self.STATE.child_modules.append(self.initrec_form_metadata) + self.initrec_form_metadata_box.setContentWidget(self.initrec_form_metadata) + self.container_layout.addWidget(self.initrec_form_metadata_box) + + # --- CUSTOM LOGIC --- + # ** 'Wiedereintrittsdatum' -> only relevant without company + search_res = search_widgets_by_key( + self.initrec_form_metadata.widget_registry, "wiedereintrittsdatum" + ) + print(f">>>> {pformat(search_res)}") + assert len(search_res) == 1 + field_wiedereintrittsdatum = search_res[0]["widget"] + assert isinstance(field_wiedereintrittsdatum, FlexibleDateInput) + self.field_wiedereintrittsdatum = field_wiedereintrittsdatum + + # ** person + self.initrec_form_person_box = CollapsibleBox("Angaben zur Person") + ent_type = EntityType.PERSON + self.initrec_form_person = InitRecForm(self.STATE.initrec_form_states[ent_type]) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_person.STATE + + self.STATE.child_modules.append(self.initrec_form_person) + self.initrec_form_person_box.setContentWidget(self.initrec_form_person) + self.container_layout.addWidget(self.initrec_form_person_box) + + # --- CUSTOM LOGIC --- + # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' + person_location = search_widgets_by_key( + self.initrec_form_person.widget_registry, + f"Stammdaten{COLUMN_SEP}aufenthaltsort", + ) + assert len(person_location) == 1 + self.person_location = cast(QComboBox, person_location[0]["widget"]) + assert isinstance(self.person_location, QComboBox) + self.person_location.currentIndexChanged.connect(self._custom_county_selection) + + selection_county = search_widgets_by_key( + self.initrec_form_person.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + ) + assert len(selection_county) == 1 + self.selection_county = cast(QComboBox, selection_county[0]["widget"]) + assert isinstance(self.selection_county, QComboBox) + self.selection_county.setProperty("styleClass", "stempel") + self.selection_county.setEnabled(False) + + # ** company + ent_type = EntityType.COMPANY + self.initrec_form_company_box = CollapsibleBox("Angaben zum Unternehmen") + self.initrec_form_company = InitRecForm(self.STATE.initrec_form_states[ent_type]) + self.STATE.child_modules.append(self.initrec_form_company) + + assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_company.STATE + + target_idx = self.container_layout.indexOf(self.initrec_form_person_box) + self.initrec_form_company_box.setContentWidget(self.initrec_form_company) + self.container_layout.insertWidget(target_idx, self.initrec_form_company_box) # --- CUSTOM LOGIC --- # ** fill 'Kontaktperson -> Namen Unternehmen' - search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") + search_res = search_widgets_by_key( + self.initrec_form_company.widget_registry, "Partnersuche" + ) assert len(search_res) == 1 search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) self.search_widget_trigger = cast( QLineEdit, search_widget.company_widgets["ma_unternehmensname"] ) - text_widget_set = search_widgets_by_key( - self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" + + # ** 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, ) - assert len(text_widget_set) == 1 - self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) - self.search_widget_trigger.textChanged.connect( - self._custom_set_company_name_contact_person + 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: + 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) ) - # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' - person_location = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" + def _request_company_profile(self) -> None: + assert self.STATE.ids.un_id is not None, ( + "requested company profile for a company which is not known (no ID)" ) - assert len(person_location) == 1 - self.person_location = cast(QComboBox, person_location[0]["widget"]) - assert isinstance(self.person_location, QComboBox) - self.person_location.currentIndexChanged.connect(self._custom_county_selection) - - selection_county = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + req_state = Page_CompanyProfile_State( + session=self.STATE.session, + un_id=self.STATE.ids.un_id, ) - assert len(selection_county) == 1 - self.selection_county = cast(QComboBox, selection_county[0]["widget"]) - assert isinstance(self.selection_county, QComboBox) - self.selection_county.setProperty("styleClass", "stempel") - self.selection_county.setEnabled(False) + logger_page_initrec.debug("[Page -- InitRec] State to call: %s", req_state) + self.company_profile_requested.emit(req_state) - def _custom_set_company_name_contact_person(self, value: str) -> None: - self.text_widget_set.setText(str(value)) + 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: + # add company + self.initrec_form_company_box.setVisible(True) + self.initrec_form_metadata.top_level_form_layout.setRowVisible( + self.field_wiedereintrittsdatum, False + ) + self.comp_profile_btn.setVisible(True) + if self.STATE.ids.valid(EntityType.COMPANY): + self.comp_profile_btn.setEnabled(True) + else: + self.comp_profile_btn.setEnabled(False) + + else: + self.initrec_form_company_box.setVisible(False) + self.initrec_form_metadata.top_level_form_layout.setRowVisible( + self.field_wiedereintrittsdatum, True + ) + self.comp_profile_btn.setVisible(False) + self.comp_profile_btn.setEnabled(False) + + # ** custom logic def _custom_county_selection(self, idx: int) -> None: value = self.person_location.itemData(idx) if value == "Inland": @@ -2260,17 +3462,580 @@ class Page_InitRecCompany(QWidget): self.selection_county.style().polish(self.selection_county) self.selection_county.update() - def reset_form(self) -> None: - self.auto_form.reset_form() + # ** GUI behaviour + def _clear_info_banners(self) -> None: + while self.info_banners: + banner = self.info_banners[0] + self.info_banners_layout.removeWidget(banner) + self.info_banners.remove(banner) + banner.deleteLater() + + def _add_info_banner( + self, + text: str, + button_text: str, + button_func: Callable[[], None] | None = None, + ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + + banner = InfoBanner( + text=text, + button_text=button_text, + ) + if button_func is not None: + banner.btn_clicked.connect(button_func) + self.info_banners.append(banner) + self.info_banners_layout.addWidget(banner) + + def _disable_save(self) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None + self.save_btn.setEnabled(False) + self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) + + def _enable_save( + self, + timeout: int = 3000, + ) -> None: + assert self.save_btn is not None + assert self.edit_buttons is not None + QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) + + def _activate_delete(self) -> None: + if self._deletion_valid(): + self.delete_btn.setEnabled(True) + else: + self.delete_btn.setEnabled(False) + + def _deletion_valid(self) -> bool: + # valid_id_states: list[bool] = [] + relevant_ent_types: set[EntityType] = {EntityType.METADATA, EntityType.PERSON} + if self.STATE.recording_type is RecordingType.WITH_COMPANY: + relevant_ent_types.add(EntityType.COMPANY) + + valid_id_states = ( + form.relevant_ids_set() + for form in self.STATE.child_modules + if form.STATE.ent_type in relevant_ent_types + ) + 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( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", + ) + if not confirm: + return + + self.STATE.geloescht = True + self.save_data() + self.update_triggered.emit() + + def _auto_form_updated(self) -> None: + self._sync_GUI_to_state() + self._sync_state_to_GUI("banners") + # TODO saving logic to connect company and person if needed + + 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 + + def _load_database( + self, + ids: EntityIds, + ) -> None: + logger_initrecform.info("[Page -- InitRec] Load data method...") + logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) + self._reset() + + # ids = EntityIds( + # # un_id=3, + # # an_id=7, + # pers_id=5, + # ) + # # ids = EntityIds( + # # link_id=7, + # # ) + self.STATE.ids.update(ids, ignore_none=False) + + if ids.empty(): + logger_initrecform.debug("[Page -- InitRec] Loading: No ID set. Do nothing!") + return + + initrec = backend.page_initrec_from_db(ids) + + # TODO re-enable later + # if res.status != STATUS_HANDLER.SUCCESS: + # logger_page_consulting.error( + # ( + # "[Consulting Page] There were errors during the database " + # "loading procedure." + # ), + # stack_info=True, + # ) + + # exc_formatted = ( + # f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + # ) + # msg_box = get_message_box( + # QMessageBox.Icon.Warning, + # "Laden fehlgeschlagen", + # ( + # "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind " + # "unten angefügt." + # ), + # detailed_text=exc_formatted, + # ) + # msg_box.exec() + # return + + self._load_from_pydantic(initrec, sync_to_GUI=False) + + def _load_from_pydantic( + self, + data: InitRec, + update_only: bool = False, + sync_to_GUI: bool = True, + ) -> None: + logger_initrecform.info("[Page -- InitRec] Load from Pydantic model...") + self.STATE.ids.update(data.ids) + self.STATE.recording_type = data.recording_type + self.STATE.form_data = data.form_data + self.STATE.geloescht = True if data.geloescht else False + + # set form data to correct initrec form states + for ent_type, state in self.STATE.initrec_form_states.items(): + state.ids.update(self.STATE.ids) + state.geloescht = self.STATE.geloescht + + match ent_type: + case EntityType.COMPANY: + state.form_data = self.STATE.form_data.company + if ( + not update_only + and self.STATE.recording_type is RecordingType.WITH_COMPANY + ): + self.initrec_form_company.load_state(state) + case EntityType.PERSON: + state.form_data = self.STATE.form_data.person + if not update_only: + self.initrec_form_person.load_state(state) + case EntityType.METADATA: + state.form_data = self.STATE.form_data.metadata + logger_initrecform.info( + "[Page -- InitRec] Load METADATA from Pydantic model...\n%s", + pformat(self.STATE.form_data.metadata), + ) + self.initrec_form_metadata.load_state(state) + 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) + + def _sync_state_to_GUI( + self, + section: Literal["content", "banners", "locking"] | None, + ) -> None: + logger_page_initrec.debug( + "[Page -- InitRec] Syncing state to GUI: State IDs: %s", self.STATE.ids + ) + + if section is None or section == "content": + if not self.STATE.geloescht: + self._load_database(self.STATE.ids) + else: + 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: + self._add_info_banner( + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", + ) + + if section is None or section == "locking": + 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() + + def _sync_GUI_to_state(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Syncing GUI to state...") + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Child modules...\n%s", + pformat(self.STATE.child_modules), + ) + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Existing form states keys...\n%s", + pformat(self.STATE.initrec_form_states.keys()), + ) + + for form in self.STATE.child_modules: + # ?? is this the same reference to use a similar pattern as + # ?? the consulting page (initialise once with the same state + # ?? as reference which are then automatically updated) + # !! check if this is working as expected + # new entries' states are linked to this instance by the page state + form_state = form.get_state() + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Form state ent_type %s", + form_state.ent_type, + ) + + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: identity of form states: %s", + (self.STATE.initrec_form_states[form_state.ent_type] is form_state), + ) + assert self.STATE.initrec_form_states[form_state.ent_type] is form_state, ( + "passing by reference not working" + ) + self.STATE.ids.update(form_state.ids, ignore_none=True) + # self.STATE.form_data[form_state.ent_type] = form_state.form_data + match form_state.ent_type: + case EntityType.COMPANY: + self.STATE.form_data.company = form_state.form_data + case EntityType.PERSON: + self.STATE.form_data.person = form_state.form_data + case EntityType.METADATA: + self.STATE.form_data.metadata = form_state.form_data + 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), + ) + logger_page_initrec.debug( + "[Page -- InitRec] Syncing GUI to state: Form data after...\n%s", + pformat(self.STATE.form_data), + ) + + def load_state( + self, + new_state: Page_InitRec_State, + ) -> None: + set_page_state( + self.STATE, + new_state, + ignore_attr={"form_data", "initrec_form_states"}, + ) + self._sync_state_to_GUI(None) + + def get_state(self) -> Page_InitRec_State: + self._sync_GUI_to_state() + + return self.STATE + + def validate(self) -> list[str]: + errors: list[str] = [] + for m in self.STATE.child_modules: + if ( + m.STATE.ent_type is EntityType.COMPANY + and self.STATE.recording_type is not RecordingType.WITH_COMPANY + ): + continue + + errors.extend(m.validate()) + + errors.extend(self.docs_widget.validate()) + + return errors + + def lock(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Locking...") + self.STATE.locked = True + self._sync_state_to_GUI("locking") + + def unlock(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Unlocking...") + self.STATE.locked = False + self._sync_state_to_GUI("locking") + + def save_data(self) -> None: + logger_page_initrec.debug("[Page -- InitRec] Save button clicked...") + + logger_page_initrec.info("[Page -- InitRec] Syncing current state...") + # state = self.get_state() + self._sync_GUI_to_state() + form_data = self.STATE.form_data + assert form_data, "form data not set" + logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data)) + db_data = Page_InitRec_Form_Data(metadata={}, person={}, company={}) + + if self.STATE.ids.empty() and self.STATE.geloescht: + logger_page_initrec.debug( + "[Page -- InitRec] This was never saved in the database " + "and is marked for deletion - ignore." + ) + return + elif not self.STATE.ids.empty() and self.STATE.geloescht: + logger_page_initrec.debug( + "[Page -- InitRec] This was saved in the database " + "and is marked for deletion - fast path." + ) + 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=ts_deleted, + form_data=db_data, + docs=pydantic_docs, + ) + returned = backend.page_initrec_to_db(data_to_db) + self._load_from_pydantic(returned) + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Löschen erfolgreich", + "Der Eintrag wurde erfolgreich gelöscht", + ) + msg_box.exec() + + return + + self._disable_save() + errors = self.validate() + + if errors: + logger_page_initrec.error( + "[Page -- InitRec] Error during GUI validation phase:\n%s", pformat(errors) + ) + GUI_validation_error_handling(errors) + self._enable_save() + return + + logger_page_initrec.info("[Page -- InitRec] Success! All data is valid") + + try: + logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...") + + for ent_type, state in self.STATE.initrec_form_states.items(): + logger_page_initrec.debug( + "[Page -- InitRec] Ent type: %s, Current state CFG model: %s", + ent_type, + state.cfg.pydantic_model, + ) + match ent_type: + case EntityType.COMPANY: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.company) + db_data.company = val_data.to_db(exclude=state.cfg.ignored_keys) + case EntityType.PERSON: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.person) + db_data.person = val_data.to_db(exclude=state.cfg.ignored_keys) + case EntityType.METADATA: + val_data = state.cfg.pydantic_model(**self.STATE.form_data.metadata) + db_data.metadata = val_data.to_db(exclude=state.cfg.ignored_keys) + 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", + pformat(e.errors()), + ) + pydantic_validation_error_handling(e) + + else: + if DEBUG_NO_DATABASE: + return + # !! this code is only called if the 'try' block was successful + # save data to database + # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) + logger_page_initrec.debug( + "[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s", + pformat(db_data), + ) + + try: + # TODO change to result pattern + 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: + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Speichern fehlgeschlagen", + ( + "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=str(err), + ) + msg_box.exec() + else: + logger_page_initrec.info("[Page -- InitRec] Data saved successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Speichern erfolgreich", + "Die Daten wurden erfolgreich gespeichert", + ) + msg_box.exec() + + finally: + # always re-enable save, even if error occurred + self._enable_save() -class Page_InitRecPerson(QWidget): +class Page_CompanyProfile(QWidget): back_main_requested = Signal() # back to main page - back_requested = Signal() # back button - update_triggered = Signal() # form saved (data changed for front page) + back_requested = Signal() # back to init rec (bool: reset yes/no) + # TODO update should trigger something else + # more like listening to 'child' ("Beratungen") + # update_triggered = Signal() # form saved (data changed for front page) + consulting_requested = Signal(Page_Consulting_State) - def __init__(self): + def __init__( + self, + state: Page_CompanyProfile_State, + ): super().__init__() + self.STATE = state + + # state management + self.COMPANY_FEATURES_MASTER_DATA: dict[str, str] = { + "ma_unternehmensname": "Name:", + "ma_branche": "Branche:", + "ma_strasse": "Straße:", + "ma_hausnummer": "Hausnummer:", + "ma_plz": "PLZ:", + "ma_ort": "Ort:", + "ma_website": "Webseite:", + "ma_mail": "E-Mail:", + "ma_telefonnummer": "Telefon:", + } + # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() @@ -2285,7 +4050,596 @@ class Page_InitRecPerson(QWidget): # --- HEADER --- header_container = QWidget() header_container.setMinimumWidth(700) - header_container.setMaximumWidth(1000) + header_container.setMaximumWidth(1200) + header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + header_layout = QVBoxLayout(header_container) + header_layout.setContentsMargins(0, 0, 0, 10) + + back_btn_main = QPushButton("← Zurück zur Übersicht") + back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) + back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_main.setMinimumWidth(200) + back_btn_main.setMaximumWidth(200) + back_btn_step = QPushButton("← Zurück") + # back_btn_step.clicked.connect(self._request_initrec_company) # TODO remove + back_btn_step.clicked.connect(lambda: self.back_requested.emit()) + back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_step.setMinimumWidth(200) + back_btn_step.setMaximumWidth(200) + + title = QLabel("Unternehmensprofil") + title.setStyleSheet("font-size: 20px; font-weight: bold;") + + header_layout.setSpacing(5) + + header_layout.addSpacing(15) + header_layout.addWidget(back_btn_step) + header_layout.addWidget(back_btn_main) + header_layout.addWidget(title) + vert_layout.addWidget(header_container) + + # --- MAIN CONTENT --- + container = QWidget() + # SCROLL AREA + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1200) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # optional: remove frame to look more modern + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + vert_layout.addWidget(scroll_area) + + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + + # --- MAIN LAYOUT --- + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator1) + + layout_change_title = QHBoxLayout() + self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...") + layout_change_title.addWidget(self.change_title_text) + change_title_btn = QPushButton("ändern") + layout_change_title.addWidget(change_title_btn) + change_title_btn.clicked.connect(self.set_company_name) + container_layout.addLayout(layout_change_title) + + btn_dummy_pauschal = QPushButton("Dummy Pauschalberatung hinzufügen") + btn_dummy_pauschal.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.PAUSCHAL) + ) + container_layout.addWidget(btn_dummy_pauschal) + btn_dummy_inidividual = QPushButton("Dummy Individualberatung hinzufügen") + btn_dummy_inidividual.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.INDIVIDUAL) + ) + container_layout.addWidget(btn_dummy_inidividual) + + btn_clear_cons = QPushButton("Beratungen leeren") + btn_clear_cons.clicked.connect(self._clear_consultations) + container_layout.addWidget(btn_clear_cons) + + btn_update_cons = QPushButton("Beratungen aktualisieren") + btn_update_cons.clicked.connect(self._update_consultations) + container_layout.addWidget(btn_update_cons) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator2) + + container_layout.addSpacing(30) + + self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) + container_layout.addWidget(self.title_company_name) + + container_layout.addSpacing(20) + + # separation in left and right + main_hor_layout = QHBoxLayout() + main_hor_layout.setSpacing(20) + # main_hor_layout.setContentsMargins(20, 20, 20, 20) + + # left column + left_frame = QFrame() + left_frame.setObjectName("Segment") + left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + left_layout = QVBoxLayout(left_frame) + main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) + master_data_label = HeaderCell("Stammdaten & Vermerke") + master_data_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + left_layout.addWidget(master_data_label) + + default_labels = tuple(self.COMPANY_FEATURES_MASTER_DATA.values()) + self.table_master_data = MasterDataFormWidget(default_labels) + left_layout.addWidget(self.table_master_data) + left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?")) + + # right column + right_column_layout = QVBoxLayout() + right_column_layout.setSpacing(20) + main_hor_layout.addLayout(right_column_layout, stretch=1) + + # top right area + top_right_frame = QFrame() + top_right_frame.setObjectName("Segment") + top_right_frame.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + self.layout_cons_pauschal = QVBoxLayout(top_right_frame) + self.cons_pauschal: list[CompanyProfile_Consultation_ClickableCell] = [] + common_consultancy_label = HeaderCell("Pauschalberatung") + common_consultancy_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.layout_cons_pauschal.addWidget(common_consultancy_label) + self.add_btn_pauschal = QPushButton("+ Hinzufügen") + self.add_btn_pauschal.clicked.connect( + lambda: self._request_new_consulting(ConsultingType.PAUSCHAL) + ) + self.add_btn_pauschal.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.add_btn_pauschal.setMinimumHeight(30) + self.add_btn_pauschal.setProperty("styleClass", "flatAddButton") + self.layout_cons_pauschal.addWidget(self.add_btn_pauschal) + + # bottom right area + bottom_right_frame = QFrame() + bottom_right_frame.setObjectName("Segment") + bottom_right_frame.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + self.layout_cons_indiv = QVBoxLayout(bottom_right_frame) + self.cons_indiv: list[CompanyProfile_Consultation_ClickableCell] = [] + individual_consultancy_label = HeaderCell("Individualberatung") + individual_consultancy_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.layout_cons_indiv.addWidget(individual_consultancy_label) + self.add_btn_individual = QPushButton("+ Hinzufügen") + self.add_btn_individual.clicked.connect( + lambda: self._request_new_consulting(ConsultingType.INDIVIDUAL) + ) + self.add_btn_individual.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.add_btn_individual.setMinimumHeight(30) + self.add_btn_individual.setProperty("styleClass", "flatAddButton") + self.layout_cons_indiv.addWidget(self.add_btn_individual) + + right_column_layout.addWidget(top_right_frame) + right_column_layout.addWidget(bottom_right_frame) + right_column_layout.addStretch(1) + + container_layout.addLayout(main_hor_layout) + container_layout.addStretch() + + # --- STYLE --- + self.setStyleSheet(""" + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 3px; + } + """) + + # self.setStyleSheet(""" + # /* Hintergrund des Hauptfensters */ + # QWidget { + # background-color: #f1f5f9; + # } + + # /* Styling für unsere 3 Segmente */ + # QFrame#Segment { + # background-color: #ffffff; + # border: 1px solid #e2e8f0; + # border-radius: 12px; + # padding: 15px; + # } + + # /* Text-Styling */ + # QLabel { + # color: #334155; + # font-size: 16px; + # font-weight: bold; + # } + # """) + + def _debug_consulting_dummy( + self, + type: ConsultingType, + ): + if type is ConsultingType.PAUSCHAL: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Pauschal", + None, # type: ignore + ) + target_layout = self.layout_cons_pauschal + self.cons_pauschal.append(dummy_click_cell) + else: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Individual", + None, # type: ignore + ) + target_layout = self.layout_cons_indiv + self.cons_indiv.append(dummy_click_cell) + + target_idx = target_layout.count() - 1 + target_layout.insertWidget(target_idx, dummy_click_cell) + + # def _request_initrec_company(self) -> None: + # req_state = Page_InitRecCompany_State( + # session=self.STATE.session, + # un_id=self.STATE.rec_id, + # locked=True, + # ) + # logger_gui.debug("[Page Company Profile] State to call: %s", req_state) + # self.back_requested.emit(req_state) + + def _request_new_consulting( + self, + cons_type: ConsultingType, + ) -> None: + assert self.STATE.un_id is not None, ( + "tried to call consulting from comp profile without set ID" + ) + new_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=None, + un_id=self.STATE.un_id, + beratungs_typ=cons_type, + locked=False, + ) + self.consulting_requested.emit(new_state) + + def _request_existing_consulting( + self, + data: CompanyProfileConsultationEntry, + ) -> None: + assert self.STATE.un_id is not None, ( + "tried to call consulting from comp profile without set ID" + ) + new_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=data.cons_id, + un_id=self.STATE.un_id, + beratungs_typ=data.cons_type, + locked=True, + ) + self.consulting_requested.emit(new_state) + + def _clear_consultations(self) -> None: + logger_gui.debug("[Page Company Profile] Clear consultations...") + + while self.cons_pauschal: + widget = self.cons_pauschal[0] + self.layout_cons_pauschal.removeWidget(widget) + self.cons_pauschal.remove(widget) + widget.deleteLater() + + while self.cons_indiv: + widget = self.cons_indiv[0] + self.layout_cons_indiv.removeWidget(widget) + self.cons_indiv.remove(widget) + widget.deleteLater() + + def _update_consultations(self) -> None: + if self.STATE.un_id is None: + return + consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.un_id) + + logger_gui.debug( + "[Page -- Company Profile] Consultations: %s", pformat(consultations) + ) + + self._clear_consultations() + + for entry in consultations.pauschal: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_pauschal.count() - 1 + self.layout_cons_pauschal.insertWidget(target_idx, click_cell) + self.cons_pauschal.append(click_cell) + + for entry in consultations.individual: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_indiv.count() - 1 + self.layout_cons_indiv.insertWidget(target_idx, click_cell) + self.cons_indiv.append(click_cell) + + def update_current_page(self) -> None: + self._update_consultations() + + def _disable_add_btns(self) -> None: + self.add_btn_pauschal.setEnabled(False) + self.add_btn_individual.setEnabled(False) + + def _enable_add_btns(self) -> None: + self.add_btn_pauschal.setEnabled(True) + self.add_btn_individual.setEnabled(True) + + def _sync_state_to_GUI(self) -> None: + if self.STATE.un_id is None: + # just clear and do nothing else + logger_gui.debug("just reset, ID is None") + self.table_master_data.reset() + self.set_company_name("") + self._disable_add_btns() + return + + self._enable_add_btns() + # TODO change to correct backend function + # raise RuntimeError("TODOs to implement") + # init_rec = backend.initrec_company_get_initial_recording(self.STATE.un_id) + # ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) + # assert ma_id is not None, "no ma_id obtained" + comp_info = backend.external_company_get_info(self.STATE.un_id) + + data: dict[str, Any] = {} + + for feature, value in comp_info.items(): + if feature in self.COMPANY_FEATURES_MASTER_DATA: + data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value + + self.table_master_data.load_data(data) + self.set_company_name(data["Name:"]) + self.update_current_page() + + def _sync_GUI_to_state(self) -> None: + pass + + def validate(self) -> None: + pass + + def load_state( + self, + new_state: Page_CompanyProfile_State, + ) -> None: + logger_gui.debug("Called function to load with ID: %s", pformat(new_state.un_id)) + self.STATE.un_id = new_state.un_id + + self._sync_state_to_GUI() + + def get_state(self) -> Any: ... + + def save_data(self) -> None: ... + + def set_company_name(self, name: str) -> None: + if not name: + name = self.change_title_text.text() + if not name: + name = "n.v." + + txt = f"Unternehmen: {name}" + self.title_company_name.setText(txt) + + +class MasterDataTable(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Stammdaten Anzeige") + self.resize(450, 400) + + layout = QVBoxLayout(self) + layout.setContentsMargins(15, 15, 15, 15) + + # 1. Tabelle erstellen (0 Zeilen initial, 2 Spalten für Merkmal & Wert) + self.table = QTableWidget(0, 2) + + # --- DER TRICK FÜR DEN CLEANEN LOOK --- + self.table.horizontalHeader().hide() # Spalten-Header ausblenden + self.table.verticalHeader().hide() # Zeilen-Header (Nummerierung) ausblenden + self.table.setShowGrid(False) # Gitternetzlinien deaktivieren + + # Verhalten der Tabelle anpassen + self.table.setSelectionMode( + QAbstractItemView.SelectionMode.NoSelection + ) # Nicht auswählbar + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.NoEditTriggers + ) # Schreibgeschützt + + # Spaltenbreite steuern: + # Linke Spalte passt sich dem Text an, rechte Spalte füllt den Rest des Fensters + self.table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeMode.ResizeToContents + ) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + + layout.addWidget(self.table) + + # Daten laden + self.load_data() + self.style_table() + + def load_data(self): + + daten = [ + ("Anwendungsname", "MyQtApp Pro"), + ("Version", "2.4.1-beta"), + ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), + ("Letzter Login", "2026-06-25 09:30"), + ("Benutzer-ID", "USR-88291"), + ("Verschlüsselung", "AES-256-GCM"), + ] + + # Tabelle befüllen + self.table.setRowCount(len(daten)) + for zeile_index, (merkmal, wert) in enumerate(daten): + # Merkmal-Zelle (Links) + item_merkmal = QTableWidgetItem(merkmal) + item_merkmal.setTextAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(zeile_index, 0, item_merkmal) + + # Wert-Zelle (Rechts) + item_wert = QTableWidgetItem(wert) + item_wert.setTextAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(zeile_index, 1, item_wert) + + def style_table(self): + self.setStyleSheet(""" + QWidget { + background-color: transparent; + } + QTableWidget { + border: none; + background-color: transparent; + } + QTableWidget::item { + padding: 8px; + color: #334155; + font-size: 14px; + } + QTableWidget::item:first-column { + font-weight: bold; + color: #64748b; + padding-right: 20px; /* Mehr Abstand zum Wert */ + } + """) + + +class MasterDataFormWidget(QWidget): + def __init__( + self, + default_labels: Sequence[str], + ): + super().__init__() + self.default_labels = default_labels + self.default_data = {label: None for label in self.default_labels} + + # main layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + # form in frame + segment_frame = QFrame() + segment_frame.setObjectName("Segment") + # form layout + self.form_layout = QFormLayout(segment_frame) + self.form_layout.setSpacing(15) + self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) + + main_layout.addWidget(segment_frame) + self.style_ui() + self.reset() + + def load_data( + self, + data: dict[str, Any], + ) -> None: + self.clear_current_layout() + + for feature, value in data.items(): + label_feature = QLabel(f"{feature}") + label_feature.setObjectName("Merkmal") + + if value is None: + value = "" + + label_value = QLabel(str(value)) + label_value.setObjectName("Wert") + label_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + self.form_layout.addRow(label_feature, label_value) + + def clear_current_layout(self): + while self.form_layout.count() > 0: + item = self.form_layout.takeAt(0) + assert item is not None + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def reset(self) -> None: + self.load_data(self.default_data) + + def style_ui(self): + self.setStyleSheet(""" + /* Hintergrund des Fensters */ + QWidget { + background-color: transparent; + } + + /* Unser optisch abgetrenntes Segment */ + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 5px; + } + + /* Textgröße für alle Elemente im Formular hochsetzen */ + QLabel { + font-size: 12px; + } + + /* Linke Spalte (Merkmale): Grau und Fett */ + QLabel#Merkmal { + font-weight: bold; + /* color: #64748b; */ + color: #475569; + } + + /* Rechte Spalte (Werte): Dunkel und gut lesbar */ + QLabel#Wert { + color: #0f172a; + } + """) + + +class Page_Consulting(QWidget): + back_main_requested = Signal() # back to main page + back_requested = Signal() # back to init rec (bool: reset yes/no) + # TODO update should trigger something else + # more like listening to 'child' ("Beratungen") + update_triggered = Signal() # form saved (data changed for front page) + + def __init__( + self, + session: Session, + ): + super().__init__() + # state management + self.STATE = Page_Consulting_State( + session=session, + vorgang_id=None, + beratungs_typ=ConsultingType.PAUSCHAL, + locked=False, + ) + + # Hauptlayout der Seite + outer_layout = QHBoxLayout(self) + vert_layout = QVBoxLayout() + # main_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.addStretch(1) + outer_layout.addLayout(vert_layout, stretch=100) + outer_layout.addStretch(1) + # Optional: Damit der Container oben am Rand klebt + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # --- HEADER --- + header_container = QWidget() + header_container.setMinimumWidth(700) + header_container.setMaximumWidth(1500) header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) @@ -2301,10 +4655,12 @@ class Page_InitRecPerson(QWidget): back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) - title = QLabel("Grunderfassung Individualperson") + title = QLabel("Beratungsgespräch") title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) + + header_layout.addSpacing(15) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) @@ -2316,7 +4672,7 @@ class Page_InitRecPerson(QWidget): scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setMinimumWidth(700) - scroll_area.setMaximumWidth(1000) + scroll_area.setMaximumWidth(1500) scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # optional: remove frame to look more modern scroll_area.setFrameShape(QFrame.Shape.NoFrame) @@ -2327,70 +4683,1452 @@ class Page_InitRecPerson(QWidget): container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) - # --- AUTO FORM LAYOUT --- - container_layout.addSpacing(20) - self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) - container_layout.addWidget(self.auto_form) - self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) + # --- MAIN LAYOUT --- + if DEBUG: + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator1) - container_layout.addSpacing(15) + btn_get_data_table = QPushButton("Debug call 'get_state' from table and page") + btn_get_data_table.clicked.connect(self._debug_get_state) + container_layout.addWidget(btn_get_data_table) + btn_validate_data_table = QPushButton("Debug call 'validate' from table and page") + btn_validate_data_table.clicked.connect(self._debug_validate) + container_layout.addWidget(btn_validate_data_table) - # --- CUSTOM LOGIC --- - # ** fill 'Kontaktperson -> Namen Unternehmen' - # search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") - # assert len(search_res) == 1 - # search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) - # self.search_widget_trigger = cast( - # QLineEdit, search_widget.company_widgets["ma_unternehmensname"] - # ) - # text_widget_set = search_widgets_by_key( - # self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" - # ) - # assert len(text_widget_set) == 1 - # self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) - # self.search_widget_trigger.textChanged.connect( - # self._custom_set_company_name_contact_person - # ) + db_index_layout = QHBoxLayout() + self.db_index_field = QLineEdit(placeholderText="setze Index für VorgangID") + self.db_index_field.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + db_index_set_button = QPushButton("setze und lade Vorgang-ID (Datenbankindex)") + db_index_set_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + db_index_set_button.clicked.connect(self._debug_load_database) - # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' - person_location = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" + lock_button = QPushButton("Seite sperren") + lock_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + lock_button.clicked.connect(self._debug_load_database) + + db_index_layout.addWidget(self.db_index_field) + db_index_layout.addWidget(db_index_set_button) + container_layout.addLayout(db_index_layout) + + linking_button = QPushButton("Verlinkungsdialog öffnen") + linking_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + linking_button.clicked.connect(self._open_linking_dialogue) + container_layout.addWidget(linking_button) + + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setFrameShadow(QFrame.Shadow.Sunken) + container_layout.addWidget(separator2) + + container_layout.addSpacing(30) + + # --- BANNER --- + self.info_banners_container = QWidget() + self.info_banners: list[InfoBanner] = [] + self.info_banners_layout = QVBoxLayout(self.info_banners_container) + self.info_banners_layout.setContentsMargins(0, 0, 0, 0) + self.info_banners_layout.setSpacing(6) + 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 + self.modify_btn = modify_btn + # reset_btn = self.edit_buttons.reset_btn + # assert reset_btn + self.save_btn = self.edit_buttons.save_btn + # self.reset_btn = reset_btn + self.delete_btn = self.edit_buttons.delete_btn + + 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_form) + self.delete_btn.clicked.connect(self._delete_data) + container_layout.addWidget(self.edit_buttons) + + type_group_box = QGroupBox("Typ") + type_layout = QVBoxLayout(type_group_box) + container_layout.addWidget(type_group_box) + self.type_pauschal_btn = QRadioButton("Pauschalberatung") + self.type_individual_btn = QRadioButton("Individualberatung") + + self.type_pauschal_btn.setChecked(True) + + self.type_group = QButtonGroup() + self.type_group.addButton(self.type_pauschal_btn, 0) + self.type_group.addButton(self.type_individual_btn, 1) + self.type_group.idClicked.connect(self.type_consulting_changed) + + type_layout.addWidget(self.type_pauschal_btn) + type_layout.addWidget(self.type_individual_btn) + + # container_layout.addSpacing(50) + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + locked=self.STATE.locked, ) - assert len(person_location) == 1 - self.person_location = cast(QComboBox, person_location[0]["widget"]) - assert isinstance(self.person_location, QComboBox) - self.person_location.currentIndexChanged.connect(self._custom_county_selection) + self.table_sessions = Page_Consulting_Table(state=table_state) + self.STATE.child_modules.append(self.table_sessions) + container_layout.addWidget(self.table_sessions) - selection_county = search_widgets_by_key( - self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + # --- STYLE --- + self.setStyleSheet(""" + QFrame#Segment { + background-color: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 3px; + } + """) + + # self.setStyleSheet(""" + # /* Hintergrund des Hauptfensters */ + # QWidget { + # background-color: #f1f5f9; + # } + + # /* Styling für unsere 3 Segmente */ + # QFrame#Segment { + # background-color: #ffffff; + # border: 1px solid #e2e8f0; + # border-radius: 12px; + # padding: 15px; + # } + + # /* Text-Styling */ + # QLabel { + # color: #334155; + # font-size: 16px; + # font-weight: bold; + # } + # """) + self._sync_state_to_GUI(None) + + def _lock_page(self) -> None: + pass + + def _delete_data(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call data delete...") + assert self.STATE.vorgang_id is not None, "deletion initialised despite no index set" + confirm = get_user_confirmation( + self, + "Löschen bestätigen", + "Soll der Eintrag wirklich gelöscht werden?", ) - assert len(selection_county) == 1 - self.selection_county = cast(QComboBox, selection_county[0]["widget"]) - assert isinstance(self.selection_county, QComboBox) - self.selection_county.setProperty("styleClass", "stempel") - self.selection_county.setEnabled(False) + if not confirm: + return - # def _custom_set_company_name_contact_person(self, value: str) -> None: - # self.text_widget_set.setText(str(value)) + self.STATE.geloescht = True + self._delete_entry() + self.update_triggered.emit() - def _custom_county_selection(self, idx: int) -> None: - value = self.person_location.itemData(idx) - if value == "Inland": - self.selection_county.setEnabled(True) - self.selection_county.setProperty("styleClass", "") - self.selection_county.style().unpolish(self.selection_county) - self.selection_county.style().polish(self.selection_county) - self.selection_county.update() + def _disable_save(self) -> None: + self.save_btn.setEnabled(False) + self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled) + + def _activate_delete(self) -> None: + # if self.STATE.un_id is not None or self.STATE.pers_id is not None: + if self.STATE.vorgang_id is not None: + self.delete_btn.setEnabled(True) else: - self.selection_county.setCurrentIndex(0) - self.selection_county.setEnabled(False) - self.selection_county.setProperty("styleClass", "stempel") - self.selection_county.style().unpolish(self.selection_county) - self.selection_county.style().polish(self.selection_county) - self.selection_county.update() + self.delete_btn.setEnabled(False) - def reset_form(self) -> None: - self.auto_form.reset_form() + def _enable_save( + self, + timeout: int = 3000, + ) -> None: + QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled) + + def type_consulting_changed( + self, + button_id: int, + ) -> None: + logger_page_consulting.debug("[Consulting Page] Trigger type buttons...") + + cons_type: ConsultingType + if button_id == 0: + cons_type = ConsultingType.PAUSCHAL + elif button_id == 1: + cons_type = ConsultingType.INDIVIDUAL + else: + raise RuntimeError(f"No consulting type defined for button id: {button_id}") + + logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type) + self.STATE.beratungs_typ = cons_type + self._sync_state_to_GUI("cons_type") + + def _debug_get_state(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call get state on table...") + _ = self.table_sessions.get_state() + logger_page_consulting.debug("[Consulting Page] Call get state on page...") + state = self.get_state() + logger_page_consulting.debug("[Consulting Page] Page state:\n%s", state) + + def _debug_validate(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call validate data on table...") + errors = self.table_sessions.validate() + logger_page_consulting.debug("[Consulting Page] Table errors:\n%s", pformat(errors)) + logger_page_consulting.debug("[Consulting Page] Call get state on page...") + errors = self.validate() + logger_page_consulting.debug("[Consulting Page] Page errors:\n%s", pformat(errors)) + + def _debug_load_database(self) -> None: + value = self.db_index_field.text().strip() + if not value: + return + + try: + db_index = int(value) + except ValueError: + return + + self._load_database(db_index) + + def _open_linking_dialogue(self) -> None: + logger_page_consulting.info("[Consulting Page] Linking dialogue triggered") + + # Dialog erstellen und anzeigen + dialog_state = Page_Consulting_Linking_State( + session=self.STATE.session, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + beratungs_typ=self.STATE.beratungs_typ, + ) + dialog = Page_Consulting_LinkingDialog(dialog_state, parent=self) + + # .exec() blocks main window + if dialog.exec() == QDialog.DialogCode.Accepted: + # Hier hast du am Ende beide (oder nur eine) Entitäten auswertbar vorliegen: + dialog_state = dialog.get_state() + self.STATE.un_id = dialog_state.un_id + self.STATE.pers_id = dialog_state.pers_id + + assert self.STATE.vorgang_id is not None, ( + "cannot link consulting process which is not already saved" + ) + # TODO error handling + backend.page_consulting_eastablish_link( + vorgang_id=self.STATE.vorgang_id, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + ) + logger_page_consulting.info( + "[Consulting Page] Company selected (ID): %s, Person selected (ID): %s", + self.STATE.un_id, + self.STATE.pers_id, + ) + else: + logger_page_consulting.info("[Consulting Page] Verknüpfung abgebrochen.") + + self._sync_state_to_GUI("banners") + + def _clear_info_banners(self) -> None: + while self.info_banners: + banner = self.info_banners[0] + self.info_banners_layout.removeWidget(banner) + self.info_banners.remove(banner) + banner.deleteLater() + + def _add_info_banner( + self, + text: str, + button_text: str, + button_func: Callable[[], None] | None = None, + ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + + banner = InfoBanner( + text=text, + button_text=button_text, + ) + if button_func is not None: + banner.btn_clicked.connect(button_func) + self.info_banners.append(banner) + self.info_banners_layout.addWidget(banner) + + def _reset(self) -> None: + self.STATE.vorgang_id = None + self.STATE.cons_sessions.clear() + self.STATE.geloescht = False + + def _sync_state_to_GUI( + self, + section: Literal["content", "cons_type", "banners", "table", "locking"] | None, + ) -> None: + if section is None or section == "content": + if not self.STATE.geloescht: + self._load_database(self.STATE.vorgang_id) + else: + self._reset() + + if section is None or section == "cons_type" or section == "locking": + match self.STATE.beratungs_typ: + case ConsultingType.PAUSCHAL: + self.type_pauschal_btn.setChecked(True) + case ConsultingType.INDIVIDUAL: + self.type_individual_btn.setChecked(True) + + # deactivate if at least one ID is set (type is pre-defined by how + # the site is called) + if self.STATE.un_id or self.STATE.pers_id or self.STATE.locked: + self.type_pauschal_btn.setEnabled(False) + self.type_individual_btn.setEnabled(False) + else: + self.type_pauschal_btn.setEnabled(True) + self.type_individual_btn.setEnabled(True) + + # info banners needed + if section is None or section == "banners": + self._clear_info_banners() + if self.STATE.vorgang_id is None: + self._add_info_banner( + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", + ) + else: + if self.STATE.un_id is None and self.STATE.pers_id is None: + self._add_info_banner( + text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) + elif ( + self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL + and self.STATE.pers_id is None + ): + self._add_info_banner( + text=( + "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " + "einer Person fehlt." + ), + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) + + # child modules + # call to child modules' syncing method not needed because these are initialised by + # their own "load_state" method which again calls the modules sync method + if section is None or section == "table": + table_state = Page_Consulting_Table_State( + session=self.STATE.session, + row_states=self.STATE.cons_sessions, + locked=self.STATE.locked, + ) + self.table_sessions.load_state(table_state) + + if section is None or section == "locking": + if self.STATE.locked: + self.table_sessions.lock() + self.edit_buttons.set_modify_state(False, set_delete_btn=True) + else: + self.table_sessions.unlock() + self.edit_buttons.set_modify_state(True, set_delete_btn=False) + self._activate_delete() + + def _sync_GUI_to_state(self) -> None: + # call to child modules' syncing method not needed because their syncing method is + # called when the "get_state" method is called --> this has to be managed within + # this method + table_state = self.table_sessions.get_state() + self.STATE.cons_sessions = table_state.row_states + self._activate_delete() + + def validate(self) -> list[str]: + errors: list[str] = [] + errors.extend(self.table_sessions.validate()) + + return errors + + def load_state( + self, + new_state: Page_Consulting_State, + ) -> None: + logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state) + set_page_state(self.STATE, new_state) + + self._sync_state_to_GUI(None) + + def _load_database( + self, + cons_id: ConsId | None, + ) -> None: + # load data model from database + logger_page_consulting.info("[Consulting Page] Load data method...") + logger_page_consulting.debug("[Consulting Page] Lookup ID: %s", cons_id) + self._reset() + + if cons_id is None: + logger_page_consulting.debug( + "[Consulting Page] Loading: Lookup ID NULL. Do nothing!" + ) + return + + res = backend.page_consulting_from_db(cons_id) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "loading procedure." + ), + stack_info=True, + ) + + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Laden fehlgeschlagen", + ( + "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return + + # instantiate state from data model + consulting_process = res.unwrap() + self._load_from_pydantic(consulting_process, sync_to_GUI=False) + + def _load_from_pydantic( + self, + data: Beratungsgespraech_Vorgang, + update_only: bool = False, + sync_to_GUI: bool = True, + ) -> None: + self.STATE.vorgang_id = data.vorgang_id + self.STATE.beratungs_typ = ConsultingType(data.beratungs_typ) + self.STATE.un_id = data.un_id + self.STATE.pers_id = data.pers_id + self.STATE.titel = data.titel + self.STATE.geloescht = data.geloescht is not None + self.STATE.locked = True + + if update_only: + 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( + relevant_gui_states, rel_cons_session_states + ): + gui_state.beratung_id = pydantic_state.beratung_id + else: + self.STATE.cons_sessions.clear() + for pydantic_state in data.beratungen: + # 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) + cons_session_state = Page_Consulting_ConsultingSession_State( + session=self.STATE.session, + nutzer_id=pydantic_state.nutzer_id, + nutzer_name=pydantic_state.nutzer_name, + beratung_id=pydantic_state.beratung_id, + zeitstempel=pydantic_state.zeitstempel, + ansprechpartner=pydantic_state.ansprechpartner, + kommunikationsweg=pydantic_state.kommunikationsweg, + thema_crm_matrix=pydantic_state.thema_crm_matrix, + anmerkungen=pydantic_state.anmerkungen, + rueckmeldung=pydantic_state.rueckmeldung, + locked=self.STATE.locked, + ) + self.STATE.cons_sessions.append(cons_session_state) + + if sync_to_GUI: + self._sync_state_to_GUI(None) + + def get_state(self) -> Page_Consulting_State: + self._sync_GUI_to_state() + + return self.STATE + + def _delete_entry(self) -> None: + assert self.STATE.vorgang_id is not None and self.STATE.geloescht + logger_page_consulting.debug( + "[Consulting Page] This was saved in the database " + "and is marked for deletion - fast path." + ) + res = backend.page_consulting_delete(self.STATE.vorgang_id) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "deletion procedure." + ), + stack_info=True, + ) + + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Löschen fehlgeschlagen", + ( + "Beim Löschen der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return + + logger_page_consulting.info("[Consulting Page] Data deleted successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Löschen erfolgreich", + "Die Daten wurden erfolgreich gelöscht", + ) + msg_box.exec() + self._sync_state_to_GUI(None) + + def save_data(self) -> None: + logger_page_consulting.debug("[Consulting Page] Call saving data...") + if self.STATE.vorgang_id is None and self.STATE.geloescht: + logger_page_consulting.debug( + "[Consulting Page] This was never saved in the database " + "and is marked for deletion - ignore." + ) + return + + self._disable_save() + errors = self.validate() + + if errors: + logger_page_consulting.error( + "[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors) + ) + GUI_validation_error_handling(errors) + self._enable_save() + return + + logger_page_consulting.info("[Consulting Page] Success! All data is valid") + logger_page_consulting.info("[Consulting Page] Get form data call...") + self._sync_GUI_to_state() + + logger_page_consulting.debug("[Consulting Page] Current state:\n%s", self.STATE) + + try: + logger_page_consulting.debug("[Consulting Page] Call Pydantic...") + + pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = [] + + timestamp_deleted: datetime.datetime | None = None + if self.STATE.geloescht: + timestamp_deleted = datetime.datetime.now(datetime.UTC) + + 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.geloescht: + logger_page_consulting.debug( + ( + "[Consulting Page] Session which was never saved in the database " + "and is marked for deletion - ignore." + ) + ) + continue + + timestamp_deleted_session: datetime.datetime | None = None + if timestamp_deleted is not None: + # if process is deleted, all sessions must be marked as deleted + timestamp_deleted_session = timestamp_deleted + elif row.geloescht: + timestamp_deleted_session = datetime.datetime.now(datetime.UTC) + + cons_session_dict = dc.asdict(row) + cons_session = Beratungsgespraech_Einzelgespraech( + vorgang_id=self.STATE.vorgang_id, + geloescht=timestamp_deleted_session, + **cons_session_dict, + ) + pydantic_sessions.append(cons_session) + + consulting_process = Beratungsgespraech_Vorgang( + vorgang_id=self.STATE.vorgang_id, + un_id=self.STATE.un_id, + pers_id=self.STATE.pers_id, + titel=self.STATE.titel, + beratungs_typ=self.STATE.beratungs_typ, # type: ignore + geloescht=timestamp_deleted, + beratungen=pydantic_sessions, + ) + + except ValidationError as e: + logger_page_consulting.error( + "[Consulting Page] Error during Pydantic validation phase:\n%s", + pformat(e.errors()), + ) + pydantic_validation_error_handling(e) + else: + if DEBUG_NO_DATABASE: + return + logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.") + logger_page_consulting.debug( + "[Consulting Page] Pydantic model:\n%s", consulting_process + ) + + res = backend.page_consulting_to_db(consulting_process) + if res.status != STATUS_HANDLER.SUCCESS: + logger_page_consulting.error( + ( + "[Consulting Page] There were errors during the database " + "saving/updating procedure." + ), + stack_info=True, + ) + + exc_formatted = ( + f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" + ) + msg_box = get_message_box( + QMessageBox.Icon.Warning, + "Speichern fehlgeschlagen", + ( + "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " + "unten angefügt." + ), + detailed_text=exc_formatted, + ) + msg_box.exec() + return + + updated_consulting_process = res.unwrap() + logger_page_consulting.info( + "[Consulting Page] Load state from modified Pydantic model" + ) + self._load_from_pydantic(updated_consulting_process, update_only=True) + self.update_triggered.emit() + + logger_page_consulting.info("[Consulting Page] Data saved successfully") + msg_box = get_message_box( + QMessageBox.Icon.Information, + "Speichern erfolgreich", + "Die Daten wurden erfolgreich gespeichert", + ) + msg_box.exec() + + # self._activate_delete() + finally: + # always re-enable save, even if error occurred + self._enable_save() + + 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") + + +class Page_Consulting_LinkingDialog(QDialog): + def __init__( + self, + state: Page_Consulting_Linking_State, + parent: QWidget | None = None, + ): + super().__init__(parent) + # State and data + self.STATE = state + self.all_companies = backend.page_consulting_linking_companies(None) + self.all_persons = backend.page_consulting_linking_persons() + + self.setWindowTitle("Beziehung verknüpfen") + self.resize(700, 450) + + # --- UI LAYOUT --- + main_layout = QVBoxLayout(self) + column_layout = QHBoxLayout() + + # left column: companies + self.vbox_companies = QVBoxLayout() + self.vbox_companies.addWidget(QLabel("Unternehmen wählen (Optional):")) + self.btn_clear_company = QPushButton("Auswahl aufheben") + self.list_companies = QListWidget() + self.vbox_companies.addWidget(self.list_companies) + self.vbox_companies.addWidget(self.btn_clear_company) + + # right column: persons + self.vbox_persons = QVBoxLayout() + self.vbox_persons.addWidget(QLabel("Person wählen (Optional):")) + self.btn_clear_person = QPushButton("Auswahl aufheben") + self.list_persons = QListWidget() + self.vbox_persons.addWidget(self.list_persons) + self.vbox_persons.addWidget(self.btn_clear_person) + + column_layout.addLayout(self.vbox_companies) + column_layout.addLayout(self.vbox_persons) + main_layout.addLayout(column_layout) + + # OK / Cancel buttons + self.button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self.ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok) + self.ok_btn.setText("Verknüpfung speichern") + self.ok_btn.setEnabled(False) + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen") + main_layout.addWidget(self.button_box) + + # TODO check removal + # list_style = """ + # QListWidget::item:selected { + # background-color: #0078D4; /* Blau, wenn die Liste aktiv fokussiert ist */ + # color: white; + # } + # QListWidget::item:selected:!active { + # background-color: #CCE4F7; /* Gut sichtbares Hellblau, wenn die Liste den Fokus verliert */ + # color: black; + # } + # QListWidget::item:hover { + # background-color: #F0F0F0; /* Dezentes Grau beim Drüberfahren */ + # } + # """ + # self.list_companies.setStyleSheet(list_style) + # self.list_persons.setStyleSheet(list_style) + + # signals + self.list_companies.itemSelectionChanged.connect(self._company_selected) + self.list_persons.itemSelectionChanged.connect(self._person_selected) + + self.btn_clear_company.clicked.connect(self._clear_company_selection) + self.btn_clear_person.clicked.connect(self._clear_person_selection) + + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self._sync_state_to_GUI(reset=True) + + def _enable_person_selection( + self, + enable: bool, + ) -> None: + logger_page_consulting.debug(">>>>>>> CALL ENABLE: %s", enable) + for i in range(self.vbox_persons.count()): + item = self.vbox_persons.itemAt(i) + if not item: + continue + widget = item.widget() + logger_page_consulting.debug(">>>>>>> Type widget: %s", type(widget)) + if isinstance(widget, QWidget): + logger_page_consulting.debug(">>>>>>> Widget (enable=%s): %s", enable, widget) + widget.setEnabled(enable) + + def _reset(self) -> None: + self._clear_company_selection() + self._clear_person_selection() + + def _sync_state_to_GUI( + self, + reset: bool, + section: Literal["person_selection", "company_selection", "validation"] | None = None, + ) -> None: + if reset: + self._reset() + self._load_companies_to_list(None) + self._load_persons_to_list(None) + + if section is None or section == "person_selection": + if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: + self._enable_person_selection(False) + elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: + self._enable_person_selection(True) + if not self.list_persons.selectedItems(): + self.list_persons.blockSignals(True) + self._load_persons_to_list(self.STATE.un_id) + self.list_persons.blockSignals(False) + + if section is None or section == "company_selection": + if not self.list_companies.selectedItems(): + self.list_companies.blockSignals(True) + self._load_companies_to_list(self.STATE.filter_un_ids) + self.list_companies.blockSignals(False) + + if section is None or section == "validation": + self._validate() + + def _validate(self) -> None: + valid: bool = False + + if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: + assert self.STATE.pers_id is None + if self.STATE.un_id is not None: + valid = True + elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: + if self.STATE.pers_id is not None: + valid = True + else: + RuntimeError("Undefined consulting type") + + if valid: + self.ok_btn.setEnabled(True) + else: + self.ok_btn.setEnabled(False) + + def _reload_all_companies(self) -> None: + self.all_companies = backend.page_consulting_linking_companies(None) + + def _reload_all_persons(self) -> None: + self.all_persons = backend.page_consulting_linking_persons() + + def _load_companies_to_list( + self, + un_ids: set[RecId] | None, + ) -> None: + # search_text = self.suche_firma.text().strip().lower() + self.list_companies.clear() + # TODO check reload of companies + # companies = backend.page_consulting_linking_companies(None) + logger_page_consulting.debug( + "[Page Consulting -- Linking] Retrieved company entries:\n%s", self.all_companies + ) + + for company in self.all_companies: + if un_ids and company.un_id not in un_ids: + continue + + item = QListWidgetItem(company.display_name) + item.setData(Qt.ItemDataRole.UserRole, company) + self.list_companies.addItem(item) + + def _load_persons_to_list( + self, + un_id: RecId | None, + ) -> None: + """Lädt Personen. Wenn firma_id übergeben wird, wird gefiltert.""" + self.list_persons.clear() + + # TODO check reload of persons + # persons = backend.page_consulting_linking_persons() + logger_page_consulting.debug( + "[Page Consulting -- Linking] Retrieved person entries:\n%s", self.all_persons + ) + + for person in self.all_persons: + if un_id and un_id not in person.un_ids: + continue + + item = QListWidgetItem(person.display_name) + item.setData(Qt.ItemDataRole.UserRole, person) + self.list_persons.addItem(item) + + # --- LOGIK --- + def _company_selected(self) -> None: + items = self.list_companies.selectedItems() + if not items: + return + + company = cast( + "LinkingConsultationsEntryCompany", items[0].data(Qt.ItemDataRole.UserRole) + ) + self.STATE.un_id = company.un_id + + logger_page_consulting.debug( + "[Page Consulting -- Linking] Company selected. State is:\n%s", + pformat(self.STATE), + ) + self._sync_state_to_GUI(reset=False) + + def _person_selected(self) -> None: + items = self.list_persons.selectedItems() + if not items: + return + + person = cast( + "LinkingConsultationsEntryPerson", items[0].data(Qt.ItemDataRole.UserRole) + ) + self.STATE.pers_id = person.pers_id + + if person.un_ids: + self.STATE.filter_un_ids = person.un_ids + else: + self.STATE.filter_un_ids = None + + self._sync_state_to_GUI(reset=False) + + def _clear_company_selection(self) -> None: + logger_page_consulting.debug( + "[Page Consulting -- Linking] Clear company selection...", + ) + self.list_companies.clearSelection() + self.STATE.un_id = None + self._sync_state_to_GUI(reset=False) + + def _clear_person_selection(self) -> None: + logger_page_consulting.debug( + "[Page Consulting -- Linking] Clear person selection...", + ) + self.list_persons.clearSelection() + self.STATE.pers_id = None + self.STATE.filter_un_ids = None + self._sync_state_to_GUI(reset=False) + + def get_state(self) -> Page_Consulting_Linking_State: + return self.STATE + + +class Page_Consulting_ConsultingSession(QWidget): + """child of 'Page_Consulting_DynamicTable'""" + + delete_request = Signal(QWidget) + + def __init__( + self, + state: Page_Consulting_ConsultingSession_State, + ) -> None: + super().__init__() + assert isinstance(state, Page_Consulting_ConsultingSession_State) + self.STATE = state + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 2, 0, 2) # Sehr kompakt + layout.setSpacing(10) + layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + ROW_HEIGHT = 70 + + self.widget_registry: WidgetRegistry = {} + + user_field_def = FormField[QLineEdit]( + "Nutzername", + FormFieldType.TEXT, + readonly=True, + placeholder="wird über aktiven Nutzer bei Anlage gesetzt", + enable_uuid_key=True, + ) + _, self.user_field = build_and_add_widget(user_field_def, layout) + + # ?? date or datetime? + timestamp_def = FormField[FlexibleDateTimeInput]( + "Zeitstempel", + FormFieldType.DATETIME_FLEXIBLE, + required=True, + key="zeitstempel", + ) + _, self.timestamp = build_and_add_widget( + timestamp_def, + layout, + widget_registry=self.widget_registry, + ) + self.timestamp.set_pydatetime(datetime.datetime.now(TIMEZONE_CEST)) + + contact_def = FormField[QLineEdit]( + "Kontaktperson", + FormFieldType.TEXT, + placeholder="Freitext", + required=True, + key="ansprechpartner", + ) + _, self.contact = build_and_add_widget( + contact_def, + layout, + widget_registry=self.widget_registry, + ) + + contact_type_def = FormField[QComboBox]( + "Kontakttyp", + FormFieldType.DROPDOWN, + options=[("Persönlich", None), ("Telefon", None), ("Mail", None)], + required=True, + key="kommunikationsweg", + ) + _, self.contact_type = build_and_add_widget( + contact_type_def, + layout, + widget_registry=self.widget_registry, + ) + + crm_link_def = FormField[QLineEdit]( + "CRM-Verknüpfung", + FormFieldType.TEXT, + placeholder="CRM Links", + required=True, + key="thema_crm_matrix", + ) + _, self.crm_link = build_and_add_widget( + crm_link_def, + layout, + widget_registry=self.widget_registry, + ) + + plain_text_def = FormField[QPlainTextEdit]( + "Anmerkungen", + FormFieldType.LONGTEXT, + key="anmerkungen", + ) + _, self.plain_text = build_and_add_widget( + plain_text_def, + layout, + widget_registry=self.widget_registry, + ) + + response_def = FormField[QPlainTextEdit]( + "Rückmeldung", + FormFieldType.LONGTEXT, + key="rueckmeldung", + ) + _, self.response = build_and_add_widget( + response_def, + layout, + widget_registry=self.widget_registry, + ) + + self.btn_delete = QPushButton("✕") + self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen + self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) + + self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;") + self.btn_delete.clicked.connect(self._delete_request) + self.btn_delete.setFixedWidth(30) + layout.addWidget(self.btn_delete) + + widgets = ( + self.user_field, + self.timestamp, + self.contact, + self.contact_type, + self.crm_link, + self.plain_text, + self.response, + self.btn_delete, + ) + for widget in widgets: + widget.setFixedHeight(ROW_HEIGHT) + # layout.addWidget(widget) + + # column stretching: must be matched with header + num_widgets = len(widgets) + for i, widget in enumerate(widgets): + stretch: int + if i == (num_widgets - 1): + stretch = 0 + else: + if "plaintext" in widget.__class__.__name__.lower(): + stretch = 2 + else: + stretch = 1 + + layout.setStretch(i, stretch) + + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self._sync_state_to_GUI(None) + + def _delete_request(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.delete_request.emit(self) + + def _sync_state_to_GUI( + self, + section: Literal["values", "locking"] | None, + ) -> None: + logger_page_consulting.debug( + "[Page Consulting -- Consulting Session]: State = %s", self.STATE + ) + if section is None or section == "values": + set_widget_value(self.user_field, self.STATE.nutzer_name) + if self.STATE.zeitstempel is not None: + set_widget_value( + self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST) + ) + set_widget_value(self.contact, self.STATE.ansprechpartner) + set_widget_value(self.contact_type, self.STATE.kommunikationsweg) + set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) + set_widget_value(self.plain_text, self.STATE.anmerkungen) + set_widget_value(self.response, self.STATE.rueckmeldung) + + if section is None or section == "locking": + if self.STATE.locked: + lock_form(self.widget_registry) + self.btn_delete.setEnabled(False) + else: + unlock_form(self.widget_registry) + self.btn_delete.setEnabled(True) + + def _sync_GUI_to_state(self) -> None: + self.STATE.zeitstempel = self.timestamp.get_pydatetime() + self.STATE.ansprechpartner = get_widget_value(self.contact) + self.STATE.kommunikationsweg = get_widget_value(self.contact_type) + self.STATE.thema_crm_matrix = get_widget_value( + self.crm_link + ) # TODO: change to correct link + self.STATE.anmerkungen = get_widget_value(self.plain_text) + self.STATE.rueckmeldung = get_widget_value(self.response) + + def get_state(self) -> Page_Consulting_ConsultingSession_State: + self._sync_GUI_to_state() + + return self.STATE + + def validate(self) -> list[str]: + logger_page_consulting.debug( + ">>>>> Call validate on Session. Widget registry:\n%s", + pformat_registry(self.widget_registry), + ) + return validate_form_data(self.widget_registry) + + def save_data(self) -> None: + pass + + def load_state( + self, + new_state: Page_Consulting_ConsultingSession_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") + + +class Page_Consulting_Table(QWidget): + """child of 'Page_Consulting'""" + + def __init__( + self, + state: Page_Consulting_Table_State, + ) -> None: + super().__init__() + self.STATE = state + + self.setWindowTitle("Native Dynamische Liste") + self.resize(600, 400) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 15, 0, 15) + main_layout.setSpacing(10) + + # --- BUTTON (ADD) --- + self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen") + self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_add.setProperty("styleClass", "addButton") + main_layout.addWidget(self.btn_add) + self.btn_add.clicked.connect(lambda: self.add_row(None, False)) + + # --- HEADER --- + header_widget = QWidget() + header_layout = QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 5, 0, 5) + header_layout.setSpacing(10) + + lbl_user_field = QLabel("Wer") + lbl_timestamp = QLabel("Wann") + lbl_contact = QLabel("Mit wem") + lbl_contact_type = QLabel("Wie") + lbl_crm_link = QLabel("Was") + lbl_plain_text = QLabel("Anmerkungen") + lbl_response = QLabel("Rückmeldung") + lbl_action_delete = QLabel("") + lbl_action_delete.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl_action_delete.setFixedWidth(30) + + labels = ( + lbl_user_field, + lbl_timestamp, + lbl_contact, + lbl_contact_type, + lbl_crm_link, + lbl_plain_text, + lbl_response, + lbl_action_delete, + ) + + header_font = QFont() + header_font.setBold(True) + for lbl in labels: + lbl.setFont(header_font) + lbl.setStyleSheet("color: #666666;") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + header_layout.addWidget(lbl) + + plaintext_labels = {lbl_plain_text, lbl_response} + + num_labels = len(labels) + for i, widget in enumerate(labels): + stretch: int + if i == (num_labels - 1): + stretch = 0 + else: + if widget in plaintext_labels: + stretch = 2 + else: + stretch = 1 + + header_layout.setStretch(i, stretch) + + main_layout.addWidget(header_widget) + + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + main_layout.addWidget(separator) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QScrollArea.Shape.NoFrame) + + self.container_widget = QWidget() + self.container_layout = QVBoxLayout(self.container_widget) + self.container_layout.setContentsMargins(0, 5, 0, 5) + self.container_layout.setSpacing(6) + self.container_layout.addStretch() + + self.scroll_area.setWidget(self.container_widget) + main_layout.addWidget(self.scroll_area) + + # --- STYLING (QSS) --- + # self.setStyleSheet(""" + # QWidget { + # background-color: #1e1e24; + # color: #e0e0e6; + # font-family: 'Segoe UI', sans-serif; + # font-size: 13px; + # } + # #HeaderWidget QLabel { + # font-weight: bold; + # color: #888892; + # font-size: 11px; + # letter-spacing: 1px; + # } + # QLineEdit { + # background-color: #2a2a32; + # border: 1px solid #3f3f4a; + # border-radius: 6px; + # padding: 6px 10px; + # color: white; + # } + # QLineEdit:focus { + # border: 1px solid #007acc; + # } + # QPushButton { + # background-color: #007acc; + # color: white; + # border: none; + # border-radius: 6px; + # padding: 8px 15px; + # font-weight: bold; + # } + # QPushButton:hover { + # background-color: #0098ff; + # } + # #DeleteButton { + # background-color: transparent; + # color: #ff5555; + # border: 1px solid #3f3f4a; + # font-size: 14px; + # padding: 4px; + # } + # #DeleteButton:hover { + # background-color: #ff3333; + # color: white; + # border-color: #ff3333; + # } + # QScrollArea { + # background-color: transparent; + # } + # """) + + def add_row( + self, + row_state: Page_Consulting_ConsultingSession_State | None, + state_exists: bool, + ) -> None: + if row_state is None: + row_state = Page_Consulting_ConsultingSession_State( + session=self.STATE.session, + beratung_id=None, + nutzer_id=self.STATE.session.user_id, + nutzer_name=self.STATE.session.user_name, + locked=False, + ) + + row = Page_Consulting_ConsultingSession(state=row_state) + row.delete_request.connect(self.remove_row) + + index = self.container_layout.count() - 1 + self.container_layout.insertWidget(index, row) + self.STATE.child_modules.append(row) + if not state_exists: + self.STATE.row_states.append(row.get_state()) + + def remove_row( + self, + table_row_widget: Page_Consulting_ConsultingSession, + ) -> None: + self.container_layout.removeWidget(table_row_widget) + self.STATE.child_modules.remove(table_row_widget) + # do not remove the corresponding state since it is tracked + # to be post-processed when the database saving operation is triggered + table_row_widget.deleteLater() + + def remove_all_rows(self) -> None: + while self.STATE.child_modules: + self.remove_row(self.STATE.child_modules[0]) + + def _sync_state_to_GUI( + self, + section: Literal["rows", "locking"] | None, + ) -> None: + self.setUpdatesEnabled(False) + + if section is None or section == "rows": + self.remove_all_rows() + # 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.geloescht: + self.add_row(row_state, True) + + if section is None or section == "locking": + if self.STATE.locked: + self.btn_add.setEnabled(False) + for m in self.STATE.child_modules: + m.lock() + else: + self.btn_add.setEnabled(True) + for m in self.STATE.child_modules: + m.unlock() + + self.setUpdatesEnabled(True) + + def _sync_GUI_to_state(self) -> None: + logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") + logger_page_consulting.debug( + ( + "[Page -- Consulting] Table: Row data is managed when entries are added. " + "There is no action needed. All states are obtained by reference and set " + "by their row components/widgets. Therefore, this method calls the " + "method `get_state` of all ACTIVE components to update their respective " + "states with the current user inputs." + ) + ) + + logger_page_consulting.debug( + "[Page -- Consulting] Table - 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 row in self.STATE.child_modules: + _ = row.get_state() + + def get_state(self) -> Page_Consulting_Table_State: + logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") + self._sync_GUI_to_state() + + logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) + + return self.STATE + + def load_state( + self, + new_state: Page_Consulting_Table_State, + ) -> None: + logger_page_consulting.debug( + "[Page -- Consulting] Table: Call loading from state, %s", new_state + ) + set_page_state(self.STATE, new_state) + self._sync_state_to_GUI(None) + + def validate(self) -> list[str]: + errors: list[str] = [] + for m in self.STATE.child_modules: + errors.extend(m.validate()) + + return errors + + def save_data(self) -> None: + pass + + 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") + + +class InfoBanner(QFrame): + btn_clicked = Signal() + + def __init__( + self, + text: str, + button_text: str, + parent: QWidget | None = None, + ): + super().__init__(parent) + + self.setStyleSheet(""" + InfoBanner { + background-color: #FFF3CD; + border: 1px solid #FFEBAA; + border-radius: 6px; + } + QLabel { + color: #856404; + font-size: 13px; + } + QPushButton { + background-color: #856404; + color: white; + border-radius: 4px; + padding: 4px 10px; + font-weight: bold; + } + QPushButton:hover { + background-color: #6C5204; + } + """) + layout = QHBoxLayout(self) + layout.setContentsMargins(15, 10, 15, 10) + + self.label = QLabel(text) + layout.addWidget(self.label) + layout.addStretch() + + if button_text: + self.button = QPushButton(button_text) + layout.addWidget(self.button) + self.button.clicked.connect(self.btn_clicked.emit) + + +# // test components + + +#################################################################### def clear_layout( @@ -2399,7 +6137,6 @@ def clear_layout( if layout is None: return - # for idx in range(start_idx, layout.count()): while layout.count(): child = layout.takeAt(0) if child is None: @@ -2413,9 +6150,142 @@ def clear_layout( clear_layout(child.layout()) +class _ModifyButton(QPushButton): + modify_activated = Signal() + modify_deactivated = Signal() + + def __init__( + self, + external_state_management: bool, + txt_activate: str = "Bearbeitung aktivieren (Strg + B)", + txt_deactivate: str = "Bearbeitung deaktivieren (Strg + B)", + shortcut: str = "Ctrl+B", + parent: QWidget | None = None, + ) -> None: + super().__init__(txt_activate, parent=parent) + self.txt_activate = txt_activate + self.txt_deactivate = txt_deactivate + self.custom_shortcut: ( + QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int + ) = shortcut + self.is_modify_state = False + self.external_state_management = external_state_management + + self.setShortcut(self.custom_shortcut) + + self.clicked.connect(self._clicked) + + def set_modify_state( + self, + enable_modification: bool, + ) -> None: + if enable_modification: + self.setText(self.txt_deactivate) + else: + self.setText(self.txt_activate) + + self.setShortcut(self.custom_shortcut) + self.is_modify_state = enable_modification + + def _clicked(self) -> None: + if self.is_modify_state: + self.setText(self.txt_activate) + self.modify_deactivated.emit() + else: + self.setText(self.txt_deactivate) + self.modify_activated.emit() + + self.setShortcut(self.custom_shortcut) + if not self.external_state_management: + self.is_modify_state = not self.is_modify_state + + @override + def setEnabled( + self, + enabled: bool, + ) -> None: + super().setEnabled(enabled) + if enabled: + self.setShortcut(self.custom_shortcut) + + @override + def setShortcut( + self, + key: QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int, + ) -> None: + self.custom_shortcut = key + super().setShortcut(key) + + +class EditButtons(QWidget): + def __init__( + self, + add_modify_btn: bool, + add_reset_btn: bool, + ) -> None: + super().__init__() + + layout_btn = QHBoxLayout(self) + layout_btn.setContentsMargins(0, 0, 0, 0) + # modify + self.modify_button: _ModifyButton | None = None + if add_modify_btn: + self.modify_button = _ModifyButton(external_state_management=True) + self.modify_button.setFixedHeight(50) + self.modify_button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + layout_btn.addWidget(self.modify_button) + + # save + self.save_btn_txt_enabled = "Speichern (Strg + S)" + self.save_btn_txt_disabled = "Wird gespeichert..." + self.save_btn = QPushButton(self.save_btn_txt_enabled) + self.save_btn.setShortcut("Ctrl+S") + self.save_btn.setFixedHeight(50) + self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + layout_btn.addWidget(self.save_btn) + # reset + self.reset_btn: QPushButton | None = None + if add_reset_btn: + self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") + self.reset_btn.setShortcut("Ctrl+Z") + self.reset_btn.setFixedHeight(50) + self.reset_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + layout_btn.addWidget(self.reset_btn) + # delete + self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") + self.delete_btn.setShortcut("Ctrl+L") + self.delete_btn.setFixedHeight(50) + self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + layout_btn.addWidget(self.delete_btn) + + def set_modify_state( + 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): def __init__(self): super().__init__() + + # STATE MANAGEMENT + session = Session(user_id=42, user_name="TEST-USER") + self.STATE = Page_MainPage_State(session=session) + self._history_stack: list[int] = [] + self.setWindowTitle("Master") self.resize(1800, 1000) @@ -2431,23 +6301,44 @@ class MainWindow(QMainWindow): self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' - self.new_entry_select = NewEntrySelect_view() + 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_entry_select.company_requested.connect(self.show_page_initrec_company) - self.new_entry_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) - # SITE: 'Grunderfassung Unternehmen' - self.initrec_company = Page_InitRecCompany() - self.initrec_company.back_main_requested.connect(self.show_main_page) - self.initrec_company.back_requested.connect(self.show_new_entry_select) - self.initrec_company.update_triggered.connect(self.update_grid) - self.stack.addWidget(self.initrec_company) - # SITE: 'Grunderfassung Person' - self.initrec_person = Page_InitRecPerson() - self.initrec_person.back_main_requested.connect(self.show_main_page) - self.initrec_person.back_requested.connect(self.show_new_entry_select) - self.initrec_person.update_triggered.connect(self.update_grid) - self.stack.addWidget(self.initrec_person) + + # SITE: 'Grunderfassung' + newinitrec_state = Page_InitRec_State( + session=session, + ids=EntityIds(), + recording_type=RecordingType.WITH_COMPANY, + form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), + locked=False, + ) + self.initrec = Page_InitRec(newinitrec_state) + self.initrec.back_main_requested.connect(self.show_main_page) + self.initrec.back_requested.connect(self.simple_go_back) + self.initrec.company_profile_requested.connect(self.show_page_company_profile) + self.initrec.update_triggered.connect(self.update_grid) + self.stack.addWidget(self.initrec) + + # SITE: 'Unternehmensprofil' + comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) + self.company_profile = Page_CompanyProfile(comp_profile_state) + self.company_profile.back_main_requested.connect(self.show_main_page) + # TODO remove + # self.company_profile.back_requested.connect(self.show_page_initrec_company) + self.company_profile.back_requested.connect(self.simple_go_back) + self.company_profile.consulting_requested.connect(self.show_page_consulting) + self.stack.addWidget(self.company_profile) + + # SITE: 'Beratungsgespräch' + self.consulting_page = Page_Consulting(self.STATE.session) + self.consulting_page.back_main_requested.connect(self.show_main_page) + self.consulting_page.back_requested.connect(self.simple_go_back) + self.consulting_page.update_triggered.connect(self.update_page_company_profile) + self.stack.addWidget(self.consulting_page) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle @@ -2463,16 +6354,17 @@ class MainWindow(QMainWindow): vert_layout.addWidget(new_btn) if DEBUG: - update_btn = QPushButton("UPDATE") - update_btn.setFixedWidth(100) - update_btn.setFixedHeight(40) - update_btn.clicked.connect(self.update_grid) - clear_btn = QPushButton("CLEAR") - clear_btn.setFixedWidth(100) - clear_btn.setFixedHeight(40) - clear_btn.clicked.connect(self._clear_layout) - vert_layout.addWidget(update_btn) - vert_layout.addWidget(clear_btn) + # TODO reactivate + # update_btn = QPushButton("UPDATE") + # update_btn.setFixedWidth(100) + # update_btn.setFixedHeight(40) + # update_btn.clicked.connect(self.update_grid) + # clear_btn = QPushButton("CLEAR") + # clear_btn.setFixedWidth(100) + # clear_btn.setFixedHeight(40) + # clear_btn.clicked.connect(self._clear_layout) + # vert_layout.addWidget(update_btn) + # vert_layout.addWidget(clear_btn) separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) separator.setFrameShadow(QFrame.Shadow.Sunken) @@ -2543,75 +6435,106 @@ class MainWindow(QMainWindow): def update_grid(self) -> None: clear_layout(self.grid) - data = backend.front_get_company_list() + data = backend.main_page_get_entries() for entry in data: self.add_row_to_grid(entry) + # TODO rework def add_row_to_grid( self, - entry: backend.FrontpageCompany, + entry: types.MainPageEntry, ): row = self.current_row # NAME - cell_name = ClickableCell(entry.name, entry) + cell_name = MainPage_ClickableCell(entry.display_name, entry) cell_name.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_name, row, 0) # DATE - cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) - cell_date = ClickableCell(cell_date_name, entry) + cell_date_name = entry.dt_updated.date().strftime(DATE_FMT) + cell_date = MainPage_ClickableCell(cell_date_name, entry) cell_date.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_date, row, 4) - # self.grid.addWidget(ClickableCell(entry["c2"], entry), row, 1) - - # c3_value = entry.get("c3") - # if c3_value: - # self.grid.addWidget(ClickableCell(c3_value, entry), row, 2) - # else: - # empty_box = QFrame() - # empty_box.setStyleSheet( - # "QFrame { background-color: #f8fafc; border: 2px - # dashed #e2e8f0; border-radius: 8px; }" - # ) - # self.grid.addWidget(empty_box, row, 2) - - # self.grid.addWidget(ClickableCell(entry.get("c4", ""), entry), row, 3) - # self.grid.addWidget(ClickableCell(entry.get("date", ""), entry), row, 4) - self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen + # TODO rework def goto_initial_recording( self, - data: backend.FrontpageCompany, + data: types.MainPageEntry, ): - if data.is_company: - self.initrec_company.auto_form.load_data(data.erfassung_id) - self.stack.setCurrentWidget(self.initrec_company) - else: - self.initrec_person.auto_form.load_data(data.erfassung_id) - self.stack.setCurrentWidget(self.initrec_person) + new_state = Page_InitRec_State( + session=self.STATE.session, + ids=data.ids, + recording_type=data.recording_type, + locked=True, + ) + self.initrec.load_state(new_state) + self._navigate_to_target(self.initrec) - # 1. Daten an die Detail-Seite übergeben - # self.detail_page.update_content(data) - # 2. Auf die Detail-Seite umblättern - # self.stack.setCurrentWidget(self.detail_page) + def _navigate_to_target( + self, + widget: QWidget, + ) -> None: + curr_idx = self.stack.currentIndex() + + if curr_idx != -1: + self._history_stack.append(curr_idx) + + self.stack.setCurrentWidget(widget) + + def simple_go_back(self) -> None: + if not self._history_stack: + logger_gui.info("[main program] Called >back< on empty history") + + prev_idx = self._history_stack.pop() + self.stack.setCurrentIndex(prev_idx) def show_main_page(self): - # Zurück zur Tabelle blättern - self.stack.setCurrentWidget(self.main_page) + self._history_stack.clear() + self._navigate_to_target(self.main_page) def show_new_entry_select(self): - self.stack.setCurrentWidget(self.new_entry_select) + self._navigate_to_target(self.new_entry_select) - def show_page_initrec_company(self): - self.initrec_company.reset_form() - self.stack.setCurrentWidget(self.initrec_company) + def show_page_initrec( + self, + new_state: Page_InitRec_State, + ): + self.initrec.load_state(new_state) + self._navigate_to_target(self.initrec) - def show_page_initrec_person(self): - self.initrec_person.reset_form() - self.stack.setCurrentWidget(self.initrec_person) + # def show_page_initrec_company( + # self, + # new_state: Page_InitRecCompany_State, + # ): + # self.initrec_company.load_state(new_state) + # self._navigate_to_target(self.initrec_company) + + # def show_page_initrec_person( + # self, + # new_state: Page_InitRecPerson_State, + # ): + # self.initrec_person.load_state(new_state) + # self._navigate_to_target(self.initrec_person) + + def show_page_company_profile( + self, + new_state: Page_CompanyProfile_State, + ) -> None: + self.company_profile.load_state(new_state) + self._navigate_to_target(self.company_profile) + + def update_page_company_profile(self) -> None: + self.company_profile.update_current_page() + + def show_page_consulting( + self, + new_state: Page_Consulting_State, + ) -> None: + self.consulting_page.load_state(new_state) + self._navigate_to_target(self.consulting_page) # --- MENÜ LOGIK --- def create_menu(self): @@ -2639,14 +6562,12 @@ def global_exception_handler(exc_type, exc_value, exc_traceback): # message to user # check if QApplication exists otherwise crashing of crash handler possible) if QApplication.instance(): - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setWindowTitle("Kritischer Fehler") - msg_box.setText( - "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert." + msg_box = get_message_box( + QMessageBox.Icon.Critical, + "Kritischer Fehler", + "Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.", + error_msg, ) - # details for user or screenshots - msg_box.setDetailedText(error_msg) msg_box.exec() sys.exit(1) @@ -2664,15 +6585,37 @@ def qt_message_handler(mode, context, message): logger_gui.critical(message) -if __name__ == "__main__": +if TYPE_CHECKING: + # enable static type checking for the protocol + _m1: type[Module] = Page_Consulting + _m2: type[Module] = Page_Consulting_Table + _m3: type[Module] = Page_Consulting_ConsultingSession + _m4: type[Module] = Page_InitRec + _m5: type[Module] = InitRecForm + _f1: type[CustomForm] = DynamicListWidget + _f2: type[CustomForm] = DynamicDropdownWidgetNumeric + _f3: type[CustomForm] = DynamicDropdownWidgetOption + + +def main() -> None: sys.excepthook = global_exception_handler qInstallMessageHandler(qt_message_handler) try: app = QApplication(sys.argv) app.setStyleSheet(QSS) + + translator = QTranslator() + translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + if translator.load(QLocale.system(), "qtbase", "_", translation_path): + app.installTranslator(translator) + window = MainWindow() window.show() sys.exit(app.exec()) except Exception as err: logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True) + + +if __name__ == "__main__": + main() 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 a9a36ca..ed322c1 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -30,13 +30,31 @@ logger_base.setLevel(logging.DEBUG) # ** GUI logger_gui = logger_base.getChild("gui") logger_gui.setLevel(logging.DEBUG) + logger_search_widget = logger_gui.getChild("search_widget") -logger_search_widget.setLevel(logging.DEBUG) +logger_search_widget.setLevel(logging.WARNING) + logger_get_data = logger_gui.getChild("get_data") -logger_get_data.setLevel(logging.DEBUG) -logger_auto_form = logger_gui.getChild("get_data_auto_form") -logger_auto_form.setLevel(logging.DEBUG) +logger_get_data.setLevel(logging.WARNING) + +logger_custom_fields = logger_gui.getChild("custom_fields") +logger_custom_fields.setLevel(logging.WARNING) + +logger_page_consulting = logger_gui.getChild("page_consulting") +logger_page_consulting.setLevel(logging.DEBUG) + +logger_page_initrec = logger_gui.getChild("page_initrec") +logger_page_initrec.setLevel(logging.DEBUG) + +logger_initrecform = logger_gui.getChild("initrecform") +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) diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index ff8d5fa..c96e9ff 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -1,9 +1,277 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +import copy +import dataclasses as dc +import datetime +import enum +from collections.abc import Generator +from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict if TYPE_CHECKING: import polars as pl +UserId: TypeAlias = int +RecId: TypeAlias = int +ConsId: TypeAlias = int +IntLinkId: TypeAlias = int +ExtMaId: TypeAlias = int +ExtAnId: TypeAlias = int + PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]] + + +# TODO rename: these are not entity types or entities but different forms of +# initial recordings +class RecordingType(enum.IntEnum): + WITH_COMPANY = enum.auto() + WITHOUT_COMPANY = enum.auto() + + +class EntityType(enum.IntEnum): + METADATA = enum.auto() + COMPANY = enum.auto() + PERSON = enum.auto() + + +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIDUAL = enum.auto() + + +@dc.dataclass(slots=True, kw_only=True) +class EntityIds: + un_id: ExtMaId | None = None + an_id: ExtAnId | None = None + pers_id: RecId | None = None + link_id: IntLinkId | None = None + _field_names: frozenset[str] = dc.field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + field_names = set(f.name for f in dc.fields(EntityIds)) + field_names.remove("_field_names") + self._field_names = frozenset(field_names) + + def set_by_name( + self, + attr_name: str, + value: int | None, + ) -> None: + if attr_name not in self._field_names: + raise KeyError( + f"Attribute {attr_name} not member of this class. Valid " + f"options: {self._field_names}" + ) + + setattr(self, attr_name, value) + + def update( + self, + ids: EntityIds, + ignore_none: bool = False, + deep: bool = False, + ) -> None: + if not isinstance(ids, EntityIds): + raise TypeError("New IDs must be have the same type as this object") + + for field in dc.fields(ids): + new_value = getattr(ids, field.name) + + if ignore_none and new_value is None: + continue + + if deep and new_value is not None: + new_value = copy.deepcopy(new_value) + + setattr(self, field.name, new_value) + + def _get_relevant_fields( + self, + ent_type: EntityType | None = None, + ) -> Generator[int | None, None, None]: + if ent_type is None: + relevant_fields = (getattr(self, field_name) for field_name in self._field_names) + elif ent_type is EntityType.COMPANY: + # RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"} + RELEVANT_ATTRIBUTES = {"un_id", "an_id"} + relevant_fields = ( + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES + ) + elif ent_type is EntityType.PERSON: + RELEVANT_ATTRIBUTES = {"pers_id"} + relevant_fields = ( + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES + ) + elif ent_type is EntityType.METADATA: + RELEVANT_ATTRIBUTES = set() + relevant_fields = ( + getattr(self, field_name) + for field_name in self._field_names + if field_name in RELEVANT_ATTRIBUTES + ) + else: + raise RuntimeError(f"Unspecified entity type {ent_type}") + + return relevant_fields + + def empty( + self, + ent_type: EntityType | None = None, + ) -> bool: + relevant_fields = self._get_relevant_fields(ent_type=ent_type) + + return not any(relevant_fields) + + def valid( + self, + ent_type: EntityType | None = None, + ) -> bool: + relevant_fields = self._get_relevant_fields(ent_type=ent_type) + + return all(relevant_fields) + + +# @dc.dataclass(slots=True, kw_only=True) +# class EntityIds: +# un_id: RecId | None = None +# pers_id: RecId | None = None +# type: dc.InitVar[RecordingType | None] = None +# _rec_type: RecordingType | None = dc.field(init=False) + +# def __post_init__(self, type: RecordingType | None) -> None: +# self._rec_type = type + +# @property +# def rec_type(self) -> RecordingType | None: +# return self._rec_type + +# def any(self) -> bool: +# return self.un_id is not None or self.pers_id is not None + +# def all(self) -> bool: +# return self.un_id is not None and self.pers_id is not None + +# def valid(self) -> bool: +# if self.rec_type is None: +# raise TypeError("Initrec type not defined. Can not determine valid state.") +# elif self.rec_type is RecordingType.COMPANY: +# return self.all() +# elif self.rec_type is RecordingType.PERSON: +# return self.any() +# else: +# raise RuntimeError( +# f"Unknown initrec type {self.rec_type}. This is not supported." +# ) + +# def set_id( +# self, +# id_: RecId | None, +# ent_type: RecordingType, +# ) -> None: +# if ent_type is RecordingType.COMPANY: +# self.un_id = id_ +# elif ent_type is RecordingType.PERSON: +# self.pers_id = id_ + +# def get_id( +# self, +# ent_type: RecordingType, +# ) -> RecId | None: +# if ent_type is RecordingType.COMPANY: +# return self.un_id +# elif ent_type is RecordingType.PERSON: +# return self.pers_id + + +# // database interaction data structures +# ** externals +class CompanyInfo(TypedDict): + ma_id: str + wce_id: str + ma_unternehmensname: str + ma_branche: str + ma_strasse: str + ma_hausnummer: str + ma_plz: str + ma_ort: str + ma_plz_postfach: str + ma_postfach: str + ma_website: str + ma_mail: str + ma_telefonnummer: str + ma_faxnummer: str + ma_ersteintrag_datum: str + ma_aktualisierung_datum: str + ma_aktualisierung_nutzer: str + ma_sollprozess: str + ma_auslaendische_mitarbeiter: str + ma_quelle_information: str + ma_bemerkung: str + ma_kontakt: str + ma_schlagworte: str + ma_archiviert: str + + +class ContactPersonInfo(TypedDict): + an_id: str + ma_id: str + wce_id: str + st_id: str + an_sachgebiet: str + an_anrede: str + an_titel: str + an_nachname: str + an_vorname: str + an_position: str + an_mail: str + an_festnetz: str + an_mobil: str + an_faxnummer: str + an_hauptansprechpartner: str + an_anrede_anschrift: str + an_bemerkung: str + an_aktualisierung_datum: str + an_aktualisierung_nutzer: str + an_letztes_kontaktdatum: str + an_ersteintrag_datum: str + an_archiviert: str + + +# ** internals +@dc.dataclass(slots=True) +class MainPageEntry: + ids: EntityIds + display_name: str + dt_updated: datetime.datetime + recording_type: RecordingType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultationEntry: + title: str + cons_id: ConsId + date_updated: datetime.datetime + cons_type: ConsultingType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultations: + pauschal: list[CompanyProfileConsultationEntry] + individual: list[CompanyProfileConsultationEntry] + + +@dc.dataclass(slots=True) +class LinkingConsultationsEntryCompany: + un_id: RecId + display_name: str + + +@dc.dataclass(slots=True) +class LinkingConsultationsEntryPerson: + pers_id: RecId + un_ids: set[RecId] + display_name: str