75 Commits

Author SHA1 Message Date
foefl 36b7c319cc update build pipeline and version 2026-07-31 16:18:10 +02:00
foefl f8b1c65221 update build pipeline and deployment config 2026-07-31 16:04:15 +02:00
foefl 37f7535693 update docs 2026-07-31 15:55:25 +02:00
foefl c91bc9022a working example for document management 2026-07-31 15:32:47 +02:00
foefl 68bd873838 fix db schema 2026-07-31 15:31:59 +02:00
foefl a6ef3d562e add DB schema for documents 2026-07-31 11:07:03 +02:00
foefl ecb9a9cf13 document selection: add Pydantic models and tests for saving/loading 2026-07-31 09:35:49 +02:00
foefl 1c586299fc prepare correct row initialisation 2026-07-30 16:45:59 +02:00
foefl 2b6268e38a begin adding state management to new document selection 2026-07-30 16:41:50 +02:00
foefl 07ae18271e prepare document selection widget 2026-07-30 15:05:56 +02:00
foefl b635e7eb75 updated main page interaction 2026-07-28 19:02:24 +02:00
foefl 02d94c4a80 adapted linking dialog for consulting sessions 2026-07-28 12:14:51 +02:00
foefl 07c2bd92da adaptive validation for initial recording page 2026-07-28 09:45:28 +02:00
foefl 2894d3a1a9 fulfil customer requirements REQ 1011, test for REQ 1013, 1014 2026-07-28 09:18:47 +02:00
foefl a515f3b820 adapt customer requirements, REQ 1006, 1007 2026-07-28 08:53:53 +02:00
foefl 449d401c04 handle metadata separately in GUI, but common in database 2026-07-27 19:50:24 +02:00
foefl ba26baa225 re-enable company profile 2026-07-27 12:38:00 +02:00
foefl 1ff55038fa basic functionality of loading/saving for initial recording with new data model 2026-07-27 11:41:24 +02:00
foefl dc9374f5b7 migration script to latest data model 2026-07-27 11:41:02 +02:00
foefl acfc4a824a continue work on new data model 2026-07-24 17:10:17 +02:00
foefl 868db09d94 iteration for new data model 2026-07-23 19:17:49 +02:00
foefl fd25576b0e sketch for new data model 2026-07-23 17:31:38 +02:00
foefl 416a686877 data modelling: extension table pattern 2026-07-22 16:19:45 +02:00
foefl 6e04a7485a added entity ID container 2026-07-22 15:26:15 +02:00
foefl ffe38a4ee7 basic cleansing and hints 2026-07-22 11:19:07 +02:00
foefl c1843702f8 linking of consultation processes to entities in the database (not tested yet) 2026-07-22 10:45:18 +02:00
foefl 1cab78b2b4 prepare linking of persons and companies 2026-07-22 10:20:10 +02:00
foefl 9a73f27518 change database schema for linking table from unique constraint to partial index 2026-07-22 10:17:00 +02:00
foefl 65e3e10e13 update database schema for linking of persons and companies 2026-07-22 09:24:51 +02:00
foefl 9392f3fc77 batch mode as default for alembic 2026-07-22 09:24:13 +02:00
foefl 7bfbdc731b prepare dialog for linking open consulting processes 2026-07-21 16:08:32 +02:00
foefl 482ae436eb added deletion for consulting with GUI logic 2026-07-21 11:00:46 +02:00
foefl 0fce42b788 change to timestamps for linking table for companies and persons 2026-07-21 08:54:41 +02:00
foefl 284196f127 set correct filter to show only not deleted entries 2026-07-20 16:16:31 +02:00
foefl c1ba3cf6e7 added new data model principle to auto forms 2026-07-20 15:48:36 +02:00
foefl a40213a1ed prepare new loading scheme for auto form data (not working yet) 2026-07-15 17:28:24 +02:00
foefl f4a4d2d98e updated schema for deletion by timestamp (equivalent to other parts of the app) 2026-07-15 17:08:06 +02:00
foefl af62025558 enable loading of consultations on company profile 2026-07-15 15:52:32 +02:00
foefl e6dc2644ac add message boxes for saving procedure in initial recording 2026-07-15 15:35:46 +02:00
foefl 393e3ba39b added info banners to initial recording 2026-07-15 15:28:54 +02:00
foefl 3bbed94048 enable/disable page modification 2026-07-15 15:05:53 +02:00
foefl eb66b435a0 refactoring and basic loading of existing consultations on the company profile 2026-07-13 17:50:59 +02:00
foefl b41d55db7d consulting page: info banners for missing links with dummy call 2026-07-13 12:03:49 +02:00
foefl 0cddb58f06 info banners for linking 2026-07-13 11:59:42 +02:00
foefl 3841b645e1 update Ruff 2026-07-13 11:23:19 +02:00
foefl 5563e5a6b9 added functionality for consulting session saving, updating and soft deleting 2026-07-11 10:02:58 +02:00
foefl 6eb358e0aa update schema for consulting session deletion tracking 2026-07-11 09:48:48 +02:00
foefl f84cbb21c0 flexible date/datetime inputs: fix validation display bug 2026-07-10 12:47:27 +02:00
foefl 7c79bf8e91 basic saving and loading of consultation sessions 2026-07-10 11:52:49 +02:00
foefl 6f7593ac9d updated data models 2026-07-10 11:52:28 +02:00
foefl 38125eb2ba update DB schema 2026-07-10 09:30:14 +02:00
foefl 14357df8cd prepare backend interaction scheme 2026-07-09 16:20:01 +02:00
foefl 39d4327e19 change database schema for consulting sessions 2026-07-09 14:25:34 +02:00
foefl 9cfe53509f refactoring 2026-07-09 13:27:52 +02:00
foefl 16ae573687 defining common interfaces for modules 2026-07-08 15:28:50 +02:00
foefl 046adfd3df updated config for Ruff and PyRight 2026-07-08 12:00:20 +02:00
foefl fae94d01a3 consulting page disable type selection if pre-defined 2026-07-08 11:38:03 +02:00
foefl 770ae11ad6 added more specific interfaces and more concise state handling 2026-07-08 10:30:04 +02:00
foefl cae623bc19 add main state and remove unnecessary lines 2026-07-06 16:09:10 +02:00
foefl 0bb6e98341 fix QSS render issue after renaming 2026-07-06 15:59:10 +02:00
foefl 62e69184d7 major refactor with new state mechanics 2026-07-06 15:57:21 +02:00
foefl 7c26c1fc2e update DB schema for consulting session 2026-07-03 13:01:15 +02:00
foefl afa8fb351d generic typing of field definitions and value getter function for single widgets 2026-07-03 13:00:56 +02:00
foefl deeb038956 more generic build function for widgets defined by form fields 2026-07-03 11:59:44 +02:00
foefl ae48f72500 implement custom datetime and date widgets based on customer requirements 2026-07-03 10:32:39 +02:00
foefl 1d353d863e added page for consulting sessions with dynamic table 2026-07-01 16:36:15 +02:00
foefl 10c8b79cb3 prepare insertion of sessions 2026-06-30 16:15:06 +02:00
foefl 9dc26e36b5 session management and prepare component interaction scheme for consultations 2026-06-30 12:36:29 +02:00
foefl 04bfac8f53 prepare consulting page 2026-06-30 10:56:11 +02:00
foefl df93914942 switch to new data model with distinct company and person data 2026-06-26 14:14:45 +02:00
foefl b9427bc756 code cleansing 2026-06-26 07:14:34 +02:00
foefl 76ff68cb43 data loading of company profiles 2026-06-25 16:26:38 +02:00
foefl b7f45066f0 draft for company profile 2026-06-25 10:41:13 +02:00
foefl b6087a5b21 fix alembic script 2026-06-19 06:28:58 +02:00
foefl 27934497d5 remove metadata creation 2026-06-17 17:40:07 +02:00
38 changed files with 9301 additions and 1561 deletions
+10 -2
View File
@@ -16,10 +16,18 @@ Die Anwendung besteht aus einem Ordner, der alle relevanten Dateien zu ihrer Aus
## Verwendung ## 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. 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. 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.
+6 -1
View File
@@ -46,6 +46,7 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():
@@ -66,7 +67,11 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: 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(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
+119
View File
@@ -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
+3 -2
View File
@@ -1,5 +1,6 @@
DOPT_STOP_FOLDER_NAME=python DOPT_STOP_FOLDER_NAME=python
DOPT_DB_CRM=data/wce_crm.db DOPT_DB_CRM=data/db/wce_crm.db
DOPT_DB_MAIN=data/wce_grunderfassung.db DOPT_DB_MAIN=data/db/wce_grunderfassung.db
DOPT_PATH_LOGGING=data/logs DOPT_PATH_LOGGING=data/logs
DOPT_PATH_FILE_STORAGE=data/files
DOPT_ALEMBIC_BASE=python/alembic DOPT_ALEMBIC_BASE=python/alembic
+29
View File
@@ -1,5 +1,34 @@
# Versionshistorie (Changelog) # 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) ## 17.06.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260617)
- Grunderfassung für Individualpersonen hinzugefügt - Grunderfassung für Individualpersonen hinzugefügt
Generated
+19 -19
View File
@@ -3571,29 +3571,29 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.10" version = "0.15.21"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust." summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["lint"] groups = ["lint"]
files = [ files = [
{file = "ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f"}, {file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"},
{file = "ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e"}, {file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"},
{file = "ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1"}, {file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609"}, {file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"},
{file = "ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f"}, {file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151"}, {file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8"}, {file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07"}, {file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48"}, {file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"},
{file = "ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5"}, {file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"},
{file = "ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed"}, {file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"},
{file = "ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188"}, {file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"},
{file = "ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e"}, {file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"},
] ]
[[package]] [[package]]
+54
View File
@@ -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
# %%
+257 -19
View File
@@ -8,43 +8,281 @@ import json
import re import re
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from pprint import pprint from pprint import pformat, pprint
from typing import Annotated, Any from typing import Annotated, Any
import babel import babel
import polars as pl import polars as pl
import sqlalchemy as sql import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from PySide6.QtCore import QDate, Qt from PySide6.QtCore import QDate, Qt
from wce_crm import constants, db from wce_crm import constants, db
from wce_crm.backend import backend 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 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 = sql.create_engine(f"sqlite:///{str(db_path)}")
engine_crm = sql.create_engine(f"sqlite:///{str(crm_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 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.""" """Ausgang für die DB: Flach, Listen sind JSON-Strings."""
nested = super().model_dump() nested = super().model_dump()
return self.__flatten_dict(nested, serialize_lists=True) 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.""" """Ausgang für die GUI: Flach, aber Listen bleiben Python-Listen."""
nested = super().model_dump() nested = super().model_dump()
return self.__flatten_dict(nested, serialize_lists=False) return self.__flatten_dict(nested, serialize_lists=False)
+11 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "wce-crm" name = "wce-crm"
version = "0.1.1dev10" version = "0.1.1dev11"
description = "GUI for CRM of NAFKA project with WCE" description = "GUI for CRM of NAFKA project with WCE"
authors = [ authors = [
{name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, {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"] requires = ["pdm-backend"]
build-backend = "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] [tool.ruff]
line-length = 94 line-length = 94
@@ -26,7 +34,7 @@ quote-style = "double"
skip-magic-trailing-comma = false skip-magic-trailing-comma = false
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I"] select = ["E", "F", "I", "SLF"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
extra-standard-library = ["typing_extensions"] extra-standard-library = ["typing_extensions"]
@@ -71,7 +79,7 @@ directory = "reports/coverage"
[tool.bumpversion] [tool.bumpversion]
current_version = "0.1.1dev10" current_version = "0.1.1dev11"
parse = """(?x) parse = """(?x)
(?P<major>0|[1-9]\\d*)\\. (?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\. (?P<minor>0|[1-9]\\d*)\\.
+16 -10
View File
@@ -45,15 +45,10 @@ Write-Output "Deleted outstanding artifacts successfully"
Write-Output "Create data folder..." Write-Output "Create data folder..."
$pattern = "dopt_nafka_wce-crm_v*" $pattern = "dopt_nafka_wce-crm_v*"
$data_folder = Join-Path -Path $ENV_PATH -ChildPath 'data'
if (-not (Test-Path -Path $data_folder)){ create_folder -base_path $ENV_PATH -folder_name 'data' -recreate
Write-Output "[PWSH] Data path not existing. Create..." create_folder -base_path $ENV_PATH -folder_name 'data/db'
New-Item -Path $data_folder -ItemType Directory create_folder -base_path $ENV_PATH -folder_name 'data/files'
}
else {
Write-Output "Data path already exists."
}
Write-Output "Building package..." Write-Output "Building package..."
@@ -93,10 +88,10 @@ if ($? -eq $false){
} }
Write-Output "Successfully installed package" Write-Output "Successfully installed package"
# copy database file # copy database files
Write-Output "Copying database files..." Write-Output "Copying database files..."
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/db/wce_grunderfassung.db' $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 Copy-Item -Path $copy_file -Destination $dest_file -Force
if ($? -eq $false){ if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying database 'Grunderfassung'" 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" 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 # copy alembic files
Write-Output "Copying alembic files..." Write-Output "Copying alembic files..."
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic' $copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic'
+1
View File
@@ -5,4 +5,5 @@
- DOPT_DB_CRM: path to CRM database, relative to base path - DOPT_DB_CRM: path to CRM database, relative to base path
- DOPT_DB_MAIN: path to main 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_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 - DOPT_ALEMBIC_BASE: path to all relevant alembic file
+26
View File
@@ -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)
+720 -128
View File
@@ -1,68 +1,59 @@
from __future__ import annotations from __future__ import annotations
import dataclasses as dc import copy
import datetime 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 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 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): K = TypeVar("K")
ma_id: str V = TypeVar("V")
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
INITREC_DOC_SERVICE: Final[DocumentService] = DocumentService(
class ContactPersonInfo(TypedDict): repo=DocumentRepository(file_storage_dir=Config.PATH_FILE_STORAGE),
an_id: str storage=FileStorageService(storage_dir=Config.PATH_FILE_STORAGE),
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
def _transform_for_gui_output( 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"])) return tuple(zip(df["dedupl"], df["ma_id"]))
def initrec_comp_search_get_info( # TODO change to direct SQL interaction
ma_id: int, def external_company_get_info(
ma_id: ExtMaId,
) -> CompanyInfo: ) -> CompanyInfo:
logger.debug("[Call backend] comp_search_get_info") logger.debug("[Call backend] comp_search_get_info")
df = db.DF_CRM_MASTER.filter(pl.col.ma_id == ma_id) 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( def initrec_comp_contact_person_search_choices(
ma_id: int | None, ma_id: ExtMaId | None,
use_both_names: bool, use_both_names: bool,
) -> tuple[tuple[str, int], ...]: ) -> tuple[tuple[str, int], ...]:
# TODO no reload functionality # TODO no reload functionality
@@ -140,7 +132,7 @@ def initrec_comp_contact_person_search_choices(
def initrec_comp_contact_person_search_get_info( def initrec_comp_contact_person_search_get_info(
an_id: int, an_id: ExtAnId,
) -> ContactPersonInfo: ) -> ContactPersonInfo:
logger.debug("[Call backend] contact_person_search_get_info") logger.debug("[Call backend] contact_person_search_get_info")
df = db.DF_CONTACT_PERSON.filter(pl.col.an_id == an_id) 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)) return cast(ContactPersonInfo, df.row(0, named=True))
def initrec_insert_initial_recording( # // internals
data: dict[str, Any], def split_dict(
) -> InitRecId: dic: dict[K, V],
logger.debug("[Call backend] insert_initial_recording") split_keys: Container[K],
stmt = db.grunderfassung.insert() ) -> tuple[dict[K, V], dict[K, V]]:
with db.ENGINE.begin() as conn: keys_in: dict[K, V] = {}
ret = conn.execute(stmt, data) keys_out = copy.deepcopy(dic)
if ret.rowcount == 0: for check_key in dic:
raise IOError("Entry was not inserted correctly") if check_key in split_keys:
keys_in[check_key] = keys_out.pop(check_key)
prim_keys = ret.inserted_primary_key return keys_in, keys_out
assert prim_keys
return prim_keys[0]
def initrec_update_initial_recording( def dic_deep_update(
id_: InitRecId, dic: dict[K, V],
data: dict[str, Any], data: Mapping[K, V],
) -> None: ) -> dict[K, V]:
logger.debug("[Call backend] update_initial_recording") dic_copy = copy.deepcopy(dic)
stmt = db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_) dic_copy.update(data)
with db.ENGINE.begin() as conn:
conn.execute(stmt, data) return dic_copy
def initrec_get_initial_recording( def _rename_db_data(
id_: InitRecId, db_data: dict[str, Any],
mapping: DualDict,
) -> dict[str, Any]: ) -> dict[str, Any]:
logger.debug("[Call backend] get_initial_recording") new_db_data = copy.deepcopy(db_data)
stmt = db.grunderfassung.select().where(db.grunderfassung.c.erfassung_id == id_)
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: 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) ret = conn.execute(stmt)
if ret.rowcount == 0: from_db = ret.mappings().all()
raise KeyError(f"Database ID {id_} not found") assert len(from_db) == 1, "not excactly one company initial recording obtained"
data_person_table = dict(from_db[0])
row = ret.fetchone() data_person_table = _rename_db_data(
assert row, "row was not obtained" 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( @wrap_result(10)
id_: InitRecId, 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: ) -> None:
logger.debug("[Call backend] delete_initial_recording") timestamp_deleted = datetime.datetime.now(datetime.UTC)
stmt = db.grunderfassung.delete().where(db.grunderfassung.c.erfassung_id == id_) dump_data: dict[str, Any] = {"geloescht": timestamp_deleted}
with db.ENGINE.begin() as conn: 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) ret = conn.execute(stmt)
if ret.rowcount == 0: results = ret.mappings().all()
raise KeyError(f"Database ID {id_} not found for deletion")
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) # // consulting page interaction
class FrontpageCompany: def page_consulting_linking_companies(
erfassung_id: InitRecId un_id: RecId | None,
# ma_id: int ) -> list[LinkingConsultationsEntryCompany]:
name: str with db.ENGINE.connect() as conn:
Metadaten_aktualisierung: datetime.datetime q_relevant_companies = sa.select(
is_company: bool 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]: def page_consulting_linking_persons() -> list[LinkingConsultationsEntryPerson]:
logger.debug("[Call backend] get_company_list") with db.ENGINE.connect() as conn:
stmt = sql.select( stmt = (
db.grunderfassung.c.erfassung_id, sa.select(
db.grunderfassung.c.Partnersuche__un_suche, db.t_grunderfassung_personen.c.pers_id,
db.grunderfassung.c.Metadaten_aktualisierung, db.t_zuordnung_personen_unternehmen.c.un_id,
).order_by(db.grunderfassung.c.Metadaten_aktualisierung.desc()) 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: with db.ENGINE.connect() as conn:
res = conn.execute(stmt) res = conn.execute(stmt)
front_page_companies: list[FrontpageCompany] = [] cons_entries_pauschal: list[CompanyProfileConsultationEntry] = []
cons_entries_individual: list[CompanyProfileConsultationEntry] = []
for entry in res: for entry in res.mappings():
erfassung_id = entry[0] cons_id = entry["vorgang_id"]
ma_id = entry[1] assert cons_id, "no VorgangID defined"
datetime_akt = cast(datetime.datetime, entry[2])
datetime_akt = datetime_akt.astimezone()
name: str = "PLATZHALTER INDIVIDUAL" datetime_updated = cast(datetime.datetime, entry["aktualisiert"])
is_company: bool = False datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST)
if ma_id is not None: base_title = entry["titel"]
comp_info = initrec_comp_search_get_info(ma_id) cons_type = ConsultingType(entry["beratungs_typ"])
name = comp_info["ma_unternehmensname"]
is_company = True
# front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt)) con_entry = CompanyProfileConsultationEntry(
front_page_companies.append( cons_id=cons_id,
FrontpageCompany( title=base_title,
erfassung_id=erfassung_id, date_updated=datetime_updated,
name=name, cons_type=cons_type,
Metadaten_aktualisierung=datetime_akt, )
is_company=is_company,
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
+196
View File
@@ -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)
+18 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import zoneinfo
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -18,10 +19,21 @@ assert BASE_PATH
class Config: class Config:
DEVELOPMENT_STATE: bool = bool(os.getenv("DOPT_DEVELOPMENT", None)) 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_KONTAKTLISTE: Path = (
DB_PATH_MAIN: Path = BASE_PATH / os.getenv( BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db")
"DOPT_DB_MAIN", "data/db/wce_grunderfassung.db" ).resolve()
) DB_PATH_MAIN: Path = (
PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log") 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" 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] = "__"
+1 -1
View File
@@ -4,6 +4,6 @@ from typing import Final
CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset( CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset(
[ [
"grunderfassung_suche", "grunderfassung_unternehmen_suche",
] ]
) )
+471 -6
View File
@@ -1,9 +1,14 @@
from __future__ import annotations from __future__ import annotations
import copy
import dataclasses as dc
import datetime import datetime
import json 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 ( from pydantic import (
AwareDatetime, AwareDatetime,
BaseModel, BaseModel,
@@ -13,8 +18,55 @@ from pydantic import (
field_validator, field_validator,
model_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)] 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: def _parse_json(value: Any) -> str:
@@ -26,9 +78,278 @@ def _parse_json(value: Any) -> str:
raise TypeError 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): class FlatBaseModel(BaseModel):
""" """
Optimised Pydantic base class, which parses JSON strings and column Optimised Pydantic base class, which parses JSON strings and column
@@ -83,7 +404,7 @@ class FlatBaseModel(BaseModel):
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def __unflatten_input( def _unflatten_input(
cls, cls,
data: Any, data: Any,
) -> Any: # type: ignore ) -> Any: # type: ignore
@@ -141,8 +462,58 @@ class FlatBaseModel(BaseModel):
return dict(items) 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 # 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_erstellung: AwareDatetime | None = None
Metadaten_aktualisierung: AwareDatetime | None = None # see above Metadaten_aktualisierung: AwareDatetime | None = None # see above
Metadaten_nutzer: str | None Metadaten_nutzer: str | None
@@ -150,7 +521,24 @@ class Grunderfassung(FlatBaseModel):
Grunderfassung_fallnummer: str Grunderfassung_fallnummer: str
Grunderfassung_notiz: str | None 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 Projektrelevanz: Grunderfassung_Projektrelevanz
Kontaktperson: Grunderfassung_Kontaktperson Kontaktperson: Grunderfassung_Kontaktperson
Stammdaten: Grunderfassung_Stammdaten Stammdaten: Grunderfassung_Stammdaten
@@ -166,7 +554,7 @@ class Grunderfassung_PartnerSuche(BaseModel):
un_suche: int | None un_suche: int | None
person_suche: int | None person_suche: int | None
kanal_aufmerksamkeit: str | None kontaktweg: str | None
class Grunderfassung_Projektrelevanz(BaseModel): class Grunderfassung_Projektrelevanz(BaseModel):
@@ -194,6 +582,7 @@ class Grunderfassung_Kontaktperson(BaseModel):
class Grunderfassung_Stammdaten(BaseModel): class Grunderfassung_Stammdaten(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
kanal_aufmerksamkeit: str | None
titel: str | None titel: str | None
anrede_anschrift: str anrede_anschrift: str
name: str name: str
@@ -295,3 +684,79 @@ class Grunderfassung_Sprachen(BaseModel):
SP_nachweis: str | None SP_nachweis: str | None
SP_art_nachweis: str | None = None SP_art_nachweis: str | None = None
SP_datum_nachweis: datetime.date | 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={},
),
}
+410 -201
View File
@@ -6,8 +6,8 @@ import re
from pathlib import Path from pathlib import Path
import polars as pl import polars as pl
import sqlalchemy as sql import sqlalchemy as sa
from sqlalchemy import Column, String, Table, TypeDecorator from sqlalchemy import Column, Index, String, Table, TypeDecorator, event, sql
from wce_crm import constants from wce_crm import constants
from wce_crm import types as t from wce_crm import types as t
@@ -37,7 +37,7 @@ class SafeDateTime(TypeDecorator):
class UTCDateTime(TypeDecorator): class UTCDateTime(TypeDecorator):
"""Safely coerces naive datetimes from SQLite into timezone-aware UTC.""" """Safely coerces naive datetimes from SQLite into timezone-aware UTC."""
impl = sql.DateTime impl = sa.DateTime
cache_ok = True cache_ok = True
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
@@ -45,128 +45,70 @@ class UTCDateTime(TypeDecorator):
if value is not None: if value is not None:
# Ensure it's converted to UTC before saving # Ensure it's converted to UTC before saving
if value.tzinfo is None: if value.tzinfo is None:
value = value.replace(tzinfo=datetime.timezone.utc) value = value.replace(tzinfo=datetime.UTC)
else: else:
value = value.astimezone(datetime.timezone.utc) value = value.astimezone(datetime.UTC)
return value return value
def process_result_value(self, value, dialect): def process_result_value(self, value, dialect):
"""Runs when fetching from the database.""" """Runs when fetching from the database."""
if value is not None and value.tzinfo is None: if value is not None and value.tzinfo is None:
# Explicitly tell Python this data *is* UTC # Explicitly tell Python this data *is* UTC
value = value.replace(tzinfo=datetime.timezone.utc) value = value.replace(tzinfo=datetime.UTC)
return value return value
MD_CRM = sql.MetaData() MD_CRM = sa.MetaData()
MD_MAIN = sql.MetaData() MD_MAIN = sa.MetaData()
ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") URL_MAIN = sa.engine.URL.create(
"sqlite+pysqlite",
database=str(constants.Config.DB_PATH_MAIN),
)
ENGINE = sa.create_engine(
URL_MAIN,
connect_args={"uri": True, "timeout": 0.5},
)
# ---------- OLD "Kontaktliste" ---------- KONTAKTLISTE_URI = constants.Config.DB_PATH_KONTAKTLISTE.as_uri() + "?mode=ro"
# 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,
# }
# def get_ext_kontaktliste( @event.listens_for(ENGINE, "connect")
# db_path: Path | None, def _db_conn_event(dbapi_connection, connection_record):
# ) -> pl.DataFrame: cursor = dbapi_connection.cursor()
# if db_path is None: # 'kontaktliste' is the alias for the second DB
# ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None) cursor.execute("ATTACH DATABASE ? AS kontaktliste", (KONTAKTLISTE_URI,))
# if ENV_PTH is None: # enable foreign keys
# raise ValueError("No database path provided or found as ENV var.") cursor.execute("PRAGMA foreign_keys=ON")
# db_path = Path(ENV_PTH) cursor.close()
# 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)
# df_kontaktliste = get_ext_kontaktliste(None) ext_crm_master: Table = Table(
# ----------------------------------------------------
ext_crm_master: sql.Table = Table(
"Master", "Master",
MD_CRM, MD_CRM,
Column("ma_id", sql.Integer, nullable=False, unique=True), Column("ma_id", sa.Integer, nullable=False, unique=True),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), Column("wce_id", sa.ForeignKey("Nutzer.wce_id")),
Column("ma_unternehmensname", sql.Text, nullable=True), Column("ma_unternehmensname", sa.Text, nullable=True),
Column("ma_branche", sql.Text, nullable=True), Column("ma_branche", sa.Text, nullable=True),
Column("ma_strasse", sql.Text, nullable=True), Column("ma_strasse", sa.Text, nullable=True),
Column("ma_hausnummer", sql.Text, nullable=True), Column("ma_hausnummer", sa.Text, nullable=True),
Column("ma_plz", sql.Text, nullable=True), Column("ma_plz", sa.Text, nullable=True),
Column("ma_ort", sql.Text, nullable=True), Column("ma_ort", sa.Text, nullable=True),
Column("ma_plz_postfach", sql.Text, nullable=True), Column("ma_plz_postfach", sa.Text, nullable=True),
Column("ma_postfach", sql.Text, nullable=True), Column("ma_postfach", sa.Text, nullable=True),
Column("ma_website", sql.Text, nullable=True), Column("ma_website", sa.Text, nullable=True),
Column("ma_mail", sql.Text, nullable=True), Column("ma_mail", sa.Text, nullable=True),
Column("ma_telefonnummer", sql.Text, nullable=True), Column("ma_telefonnummer", sa.Text, nullable=True),
Column("ma_faxnummer", sql.Text, nullable=True), Column("ma_faxnummer", sa.Text, nullable=True),
Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), Column("ma_ersteintrag_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), Column("ma_aktualisierung_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), Column("ma_aktualisierung_nutzer", sa.Text, nullable=True),
Column("ma_sollprozess", sql.Text, nullable=True), Column("ma_sollprozess", sa.Text, nullable=True),
Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True),
Column("ma_quelle_information", sql.Text, nullable=True), Column("ma_quelle_information", sa.Text, nullable=True),
Column("ma_bemerkung", sql.Text, nullable=True), Column("ma_bemerkung", sa.Text, nullable=True),
Column("ma_kontakt", sql.Boolean, nullable=True), Column("ma_kontakt", sa.Boolean, nullable=True),
Column("ma_schlagworte", sql.Text, nullable=True), Column("ma_schlagworte", sa.Text, nullable=True),
Column("ma_archiviert", sql.Boolean, nullable=True, default=False), Column("ma_archiviert", sa.Boolean, nullable=True, default=False),
) )
@@ -210,26 +152,26 @@ def get_ext_crm_master(
if not db_path.exists(): if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<") raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}") engine = sa.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_master) stmt = sa.select(ext_crm_master)
return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema) 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", "Nutzer",
MD_CRM, MD_CRM,
Column("wce_id", sql.Integer, nullable=False, unique=True), Column("wce_id", sa.Integer, nullable=False, unique=True),
Column("wce_name", sql.Text, nullable=True), Column("wce_name", sa.Text, nullable=True),
Column("wce_vorname", sql.Text, nullable=True), Column("wce_vorname", sa.Text, nullable=True),
Column("wce_kuerzel", sql.Text, nullable=True), Column("wce_kuerzel", sa.Text, nullable=True),
Column("wce_passwort", sql.Text, nullable=True), Column("wce_passwort", sa.Text, nullable=True),
Column("wce_angelegt_am", sql.DateTime, nullable=True), Column("wce_angelegt_am", sa.DateTime, nullable=True),
Column("wce_rolle", sql.Text, nullable=True), Column("wce_rolle", sa.Text, nullable=True),
Column("wce_angelegt_von", sql.Text, nullable=True), Column("wce_angelegt_von", sa.Text, nullable=True),
Column("wce_aktiv", sql.Boolean, nullable=True), Column("wce_aktiv", sa.Boolean, nullable=True),
Column("wce_letzter_login", sql.DateTime, nullable=True), Column("wce_letzter_login", sa.DateTime, nullable=True),
) )
ext_crm_nutzer_schema: t.PolarsSchema = { ext_crm_nutzer_schema: t.PolarsSchema = {
@@ -245,31 +187,31 @@ ext_crm_nutzer_schema: t.PolarsSchema = {
"wce_letzter_login": pl.Datetime, "wce_letzter_login": pl.Datetime,
} }
ext_crm_contact_person: sql.Table = Table( ext_crm_contact_person: Table = Table(
"Ansprechpartner", "Ansprechpartner",
MD_CRM, MD_CRM,
Column("an_id", sql.Integer, nullable=False, unique=True), Column("an_id", sa.Integer, nullable=False, unique=True),
Column("ma_id", sql.ForeignKey("Master.ma_id")), Column("ma_id", sa.ForeignKey("Master.ma_id")),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), Column("wce_id", sa.ForeignKey("Nutzer.wce_id")),
Column("st_id", sql.Integer, nullable=False), Column("st_id", sa.Integer, nullable=False),
Column("an_sachgebiet", sql.Text, nullable=True), Column("an_sachgebiet", sa.Text, nullable=True),
Column("an_anrede", sql.Text, nullable=True), Column("an_anrede", sa.Text, nullable=True),
Column("an_titel", sql.Text, nullable=True), Column("an_titel", sa.Text, nullable=True),
Column("an_nachname", sql.Text, nullable=True), Column("an_nachname", sa.Text, nullable=True),
Column("an_vorname", sql.Text, nullable=True), Column("an_vorname", sa.Text, nullable=True),
Column("an_position", sql.Text, nullable=True), Column("an_position", sa.Text, nullable=True),
Column("an_mail", sql.Text, nullable=True), Column("an_mail", sa.Text, nullable=True),
Column("an_festnetz", sql.Text, nullable=True), Column("an_festnetz", sa.Text, nullable=True),
Column("an_mobil", sql.Text, nullable=True), Column("an_mobil", sa.Text, nullable=True),
Column("an_faxnummer", sql.Text, nullable=True), Column("an_faxnummer", sa.Text, nullable=True),
Column("an_hauptansprechpartner", sql.Text, nullable=True), Column("an_hauptansprechpartner", sa.Text, nullable=True),
Column("an_anrede_anschrift", sql.Text, nullable=True), Column("an_anrede_anschrift", sa.Text, nullable=True),
Column("an_bemerkung", sql.Text, nullable=True), Column("an_bemerkung", sa.Text, nullable=True),
Column("an_aktualisierung_datum", SafeDateTime, 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_letztes_kontaktdatum", SafeDateTime, nullable=True),
Column("an_ersteintrag_datum", 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(): if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<") raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}") engine = sa.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_contact_person) stmt = sa.select(ext_crm_contact_person)
df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema) df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema)
df = df.with_columns( df = df.with_columns(
pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ") pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ")
@@ -322,81 +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(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( t_ext_crm_master_attach: Table = Table(
"grunderfassung", "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, MD_MAIN,
Column( Column(
"erfassung_id", "un_id",
sql.Integer, sa.Integer,
primary_key=True, primary_key=True,
autoincrement=True, autoincrement=True,
), ),
Column( Column(
"Metadaten_erstellung", "erstellt",
UTCDateTime, UTCDateTime,
nullable=True, nullable=False,
default=lambda: datetime.datetime.now(datetime.UTC), default=lambda: datetime.datetime.now(datetime.UTC),
), ),
Column( Column(
"Metadaten_aktualisierung", "geloescht",
UTCDateTime, UTCDateTime,
nullable=True, 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), default=lambda: datetime.datetime.now(datetime.UTC),
onupdate=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( Column(
"Kontaktperson__KP_name_partner", sql.Text, nullable=True "geloescht",
), # TODO: check if needed when set by trigger UTCDateTime,
Column("Kontaktperson__KP_titel", sql.Text, nullable=True), nullable=True,
Column("Kontaktperson__KP_vorname", sql.Text, nullable=True), ),
Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), Column("Metadaten_nutzer", sa.String(20), nullable=True),
Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None),
Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), Column("Arbeitserfahrung", sa.Text, nullable=True),
Column("Projektrelevanz__relevanz", sql.Text, nullable=True), Column("Grunderfassung_fallnummer", sa.Text, nullable=True),
Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True), Column("Grunderfassung_notiz", sa.Text, nullable=True),
Column("Schulbildung", sql.Text, nullable=True), Column("HoehereBildung", sa.Text, nullable=True),
Column("Sprachkenntnisse", sql.Text, nullable=True), Column("Kontaktperson__KP_adresse", sa.Text, nullable=True),
Column("Stammdaten__PLZ", sql.Text, nullable=True), Column("Kontaktperson__KP_anrede_anschrift", sa.Text, nullable=True),
Column("Stammdaten__anrede_anschrift", sql.Text, nullable=True), Column("Kontaktperson__KP_email", sa.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__alter", sql.Text, nullable=True), Column("Kontaktperson__KP_festnetznummer", sa.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__anzahl", sql.Integer, nullable=True), Column("Kontaktperson__KP_funktion_beziehung", sa.Text, nullable=True),
Column("Stammdaten__aufenthaltsort", sql.Text, nullable=True), Column("Kontaktperson__KP_mobilfunknummer", sa.Text, nullable=True),
Column("Stammdaten__bundesland", sql.Text, nullable=True), Column("Kontaktperson__KP_name", sa.Text, nullable=True),
Column("Stammdaten__land", sql.Text, nullable=True), Column("Kontaktperson__KP_name_partner", sa.Text, nullable=True),
Column("Stammdaten__email", sql.Text, nullable=True), Column("Kontaktperson__KP_titel", sa.Text, nullable=True),
Column("Stammdaten__familienstand", sql.Text, nullable=True), Column("Kontaktperson__KP_vorname", sa.Text, nullable=True),
Column("Stammdaten__festnetznummer", sql.Text, nullable=True), Column("Projektrelevanz__relevanz", sa.Text, nullable=True),
Column("Stammdaten__geburtsdatum", sql.Date, nullable=True), Column("Projektrelevanz__foerderperiode", sa.Text, nullable=True),
Column("Stammdaten__hausnummer", sql.Text, nullable=True), Column("Schulbildung", sa.Text, nullable=True),
Column("Stammdaten__herkunftsland", sql.Text, nullable=True), Column("Sprachkenntnisse", sa.Text, nullable=True),
Column("Stammdaten__mobilfunknummer", sql.Text, nullable=True), Column("Stammdaten__kanal_aufmerksamkeit", sa.Text, nullable=True),
Column("Stammdaten__name", sql.Text, nullable=True), Column("Stammdaten__PLZ", sa.Text, nullable=True),
Column("Stammdaten__ort", sql.Text, nullable=True), Column("Stammdaten__anrede_anschrift", sa.Text, nullable=True),
Column("Stammdaten__rueckkehrer", sql.Boolean, nullable=True), Column("Stammdaten__anzahl_kinder__alter", sa.Text, nullable=True),
Column("Stammdaten__staatsangehoerigkeit", sql.Text, nullable=True), Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer, nullable=True),
Column("Stammdaten__strasse", sql.Text, nullable=True), Column("Stammdaten__aufenthaltsort", sa.Text, nullable=True),
Column("Stammdaten__titel", sql.Text, nullable=True), Column("Stammdaten__bundesland", sa.Text, nullable=True),
Column("Stammdaten__vorname", sql.Text, nullable=True), Column("Stammdaten__land", sa.Text, nullable=True),
Column("WeitereInfos__WI_arbeitsstatus", sql.Text, nullable=True), Column("Stammdaten__email", sa.Text, nullable=True),
Column("WeitereInfos__WI_aufenthaltstitel", sql.Text, nullable=True), Column("Stammdaten__familienstand", sa.Text, nullable=True),
Column("WeitereInfos__WI_deutsch_sprache", sql.Text, nullable=True), Column("Stammdaten__festnetznummer", sa.Text, nullable=True),
Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True), Column("Stammdaten__geburtsdatum", sa.Date, nullable=True),
Column("WeitereInfos__WI_meldung_institution", sql.Text, 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),
) )
# MD_MAIN.create_all(ENGINE) 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,
),
)
+71 -182
View File
@@ -4,19 +4,26 @@ import dataclasses as dc
import enum import enum
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any, Final from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar
import babel import babel
from wce_crm.custom_widget_registry import CUSTOM_WIDGET_NAMES 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): class FormFieldType(enum.StrEnum):
GROUP = enum.auto() GROUP = enum.auto()
TEXT = enum.auto() TEXT = enum.auto()
LONGTEXT = enum.auto() LONGTEXT = enum.auto()
DATE = enum.auto() DATE = enum.auto()
DATE_FLEXIBLE = enum.auto()
DATETIME = enum.auto() DATETIME = enum.auto()
DATETIME_FLEXIBLE = enum.auto()
DROPDOWN = enum.auto() DROPDOWN = enum.auto()
EXTENDED_DROPDOWN = enum.auto() EXTENDED_DROPDOWN = enum.auto()
DYNAMIC_LIST = enum.auto() DYNAMIC_LIST = enum.auto()
@@ -29,7 +36,7 @@ class FormFieldType(enum.StrEnum):
@dc.dataclass(slots=True) @dc.dataclass(slots=True)
class FormField: class FormField(Generic[T]):
label: str label: str
type: FormFieldType type: FormFieldType
children: Sequence[FormField] = dc.field(default_factory=list) children: Sequence[FormField] = dc.field(default_factory=list)
@@ -125,7 +132,7 @@ class CountryList:
def get_country_list_german() -> CountryList: def get_country_list_german() -> CountryList:
locale = babel.Locale("de", "DE") locale = babel.Locale("en", "GB")
countries: list[tuple[str, str]] = [] countries: list[tuple[str, str]] = []
iso_to_country: dict[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() GERMAN_STATE_LIST: Final[CountryList] = get_list_germany_states()
INITREC_COMP_SEARCH_HEAD = [ _INITREC_COMP_SEARCH_HEAD = [
FormField( FormField(
"Suche", "Suche",
FormFieldType.EXTENDED_DROPDOWN, FormFieldType.EXTENDED_DROPDOWN,
@@ -300,52 +307,38 @@ INITREC_COMP_SEARCH_HEAD = [
info="an_position", info="an_position",
), ),
FormField( FormField(
"Wie sind Sie auf uns aufmerksam geworden?", "Kontaktweg",
FormFieldType.DROPDOWN, FormFieldType.DROPDOWN,
required=False, required=False,
key="kanal_aufmerksamkeit", key="kontaktweg",
options=[ options=[
("Agentur für Arbeit", None), ("Telefon", None),
("Ausländerbehörde", None), ("Mail", None),
("Jobcenter", None), ("Persönliches Gespräch", 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),
], ],
), ),
] ]
INITREC_CONTACT_PERSON = [ _INITREC_CONTACT_PERSON = [
FormField( FormField(
"Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)", "Name Unternehmen/Netzwerkpartner",
FormFieldType.TEXT, FormFieldType.TEXT,
key="KP_name_partner", key="KP_name_partner",
required=False, required=False,
placeholder="Text wird nach gewähltem Unternehmen angezeigt", readonly=False,
readonly=True,
), ),
FormField( FormField(
"Titel", "Titel",
FormFieldType.TEXT, FormFieldType.TEXT,
key="KP_titel", key="KP_titel",
required=False, required=False,
tooltip=(
"* nur wenn anrufende Person oder kontaktaufnehmende Person "
"nicht die zu beratende Person ist"
),
), ),
FormField( FormField(
"Anrede_Anschrift", "Anrede",
FormFieldType.TEXT, FormFieldType.DROPDOWN,
key="KP_anrede_anschrift", key="KP_anrede_anschrift",
required=True, required=True,
options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)],
), ),
FormField( FormField(
"Name", "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( FormField(
"Titel", "Titel",
FormFieldType.TEXT, FormFieldType.TEXT,
key="titel", key="titel",
required=False, required=False,
tooltip=(
"* nur wenn anrufende Person oder kontaktaufnehmende Person "
"nicht die zu beratende Person ist"
),
), ),
FormField( FormField(
"Anrede", "Anrede",
FormFieldType.TEXT, FormFieldType.DROPDOWN,
key="anrede_anschrift", key="anrede_anschrift",
required=True, required=True,
options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)],
), ),
FormField( FormField(
"Name", "Name",
@@ -419,17 +430,13 @@ INITREC_MASTER_DATA = [
"Vorname", "Vorname",
FormFieldType.TEXT, FormFieldType.TEXT,
key="vorname", key="vorname",
required=False, required=True,
), ),
FormField( FormField(
"Geburtsdatum", "Geburtsdatum",
FormFieldType.DATE, FormFieldType.DATE_FLEXIBLE,
key="geburtsdatum", key="geburtsdatum",
required=False, required=False,
tooltip=(
"* Wichtig zu erfragen, da u.a. Mindestgehaltsschwelle davon abhängt "
"(Regelung bei Ü45 Jahre)"
),
), ),
FormField( FormField(
"Herkunftsland", "Herkunftsland",
@@ -438,7 +445,6 @@ INITREC_MASTER_DATA = [
required=True, required=True,
placeholder="Suche...", placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown, options=COUNTRY_LIST.for_dropdown,
tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"),
), ),
FormField( FormField(
"Staatsangehörigkeit", "Staatsangehörigkeit",
@@ -447,7 +453,6 @@ INITREC_MASTER_DATA = [
required=False, required=False,
placeholder="Suche...", placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown, options=COUNTRY_LIST.for_dropdown,
tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"),
), ),
FormField( FormField(
"Rückkehrer", "Rückkehrer",
@@ -455,7 +460,6 @@ INITREC_MASTER_DATA = [
key="rueckkehrer", key="rueckkehrer",
required=False, required=False,
options=[("ja", None), ("nein", None)], options=[("ja", None), ("nein", None)],
tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"),
), ),
FormField( FormField(
"Wo befindet sich die Person?", "Wo befindet sich die Person?",
@@ -494,7 +498,6 @@ INITREC_MASTER_DATA = [
key="bundesland", key="bundesland",
required=False, required=False,
options=GERMAN_STATE_LIST.for_dropdown, options=GERMAN_STATE_LIST.for_dropdown,
tooltip=("nur wenn Inland angegeben"),
), ),
FormField( FormField(
"Land", "Land",
@@ -527,13 +530,11 @@ INITREC_MASTER_DATA = [
FormFieldType.TEXT, FormFieldType.TEXT,
key="familienstand", key="familienstand",
required=False, required=False,
tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung",
), ),
FormField( FormField(
"Anzahl Kinder", "Anzahl Kinder",
FormFieldType.DYNAMIC_DROPDOWN_NUMERIC, FormFieldType.DYNAMIC_DROPDOWN_NUMERIC,
required=False, required=False,
tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung",
key="anzahl_kinder", key="anzahl_kinder",
children=[ children=[
FormField( FormField(
@@ -541,7 +542,6 @@ INITREC_MASTER_DATA = [
FormFieldType.DROPDOWN, FormFieldType.DROPDOWN,
required=False, required=False,
options=[(str(x), None) for x in range(11)], options=[(str(x), None) for x in range(11)],
tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung",
key="anzahl", key="anzahl",
children=[ children=[
FormField("Alter Kind", FormFieldType.TEXT, key="alter"), FormField("Alter Kind", FormFieldType.TEXT, key="alter"),
@@ -552,7 +552,7 @@ INITREC_MASTER_DATA = [
] ]
INITREC_ADDITIONAL_DATA = [ _INITREC_ADDITIONAL_DATA = [
FormField( FormField(
"Deutsch als Kommunikationssprache", "Deutsch als Kommunikationssprache",
FormFieldType.DROPDOWN, FormFieldType.DROPDOWN,
@@ -585,11 +585,10 @@ INITREC_ADDITIONAL_DATA = [
("Chancenaufenthaltsrecht §104c AufenthG", None), ("Chancenaufenthaltsrecht §104c AufenthG", None),
("Sonstiges", None), ("Sonstiges", None),
], ],
tooltip="sofern nicht bekannt, unbedingt einfordern",
), ),
FormField( FormField(
"Gültigkeit Aufenthaltsstatus", "Gültigkeit Aufenthaltsstatus",
FormFieldType.DATE, FormFieldType.DATE_FLEXIBLE,
required=False, required=False,
key="WI_gueltigkeit_aufenthaltstitel", 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("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"),
FormField( FormField(
"Abschlussgrad laut Dokument", "Abschlussgrad laut Dokument",
@@ -665,7 +664,7 @@ INITREC_SCHOOL = [
] ]
INITREC_HIGHER_EDUCATION = [ _INITREC_HIGHER_EDUCATION = [
FormField( FormField(
"Anerkennung", "Anerkennung",
FormFieldType.TEXT, FormFieldType.TEXT,
@@ -677,10 +676,6 @@ INITREC_HIGHER_EDUCATION = [
FormFieldType.TEXT, FormFieldType.TEXT,
required=False, required=False,
key="HB_abschlussgrad", key="HB_abschlussgrad",
tooltip=(
"bitte den Titel eingeben z.B. Doktor, Diplom oder "
"Betriebswirt (Fachschulabschluss)"
),
), ),
FormField( FormField(
"Abschlussgrad laut Dokument", "Abschlussgrad laut Dokument",
@@ -699,10 +694,6 @@ INITREC_HIGHER_EDUCATION = [
FormFieldType.TEXT, FormFieldType.TEXT,
required=False, required=False,
key="HB_beruf", key="HB_beruf",
tooltip=(
"bitte spezifizieren z.B. Allgemeinmedizin, Ingenieur Maschinenbau, "
"technischer Betriebswirt Datenverarbeitung"
),
), ),
FormField( FormField(
"Land", "Land",
@@ -729,12 +720,11 @@ INITREC_HIGHER_EDUCATION = [
FormFieldType.TEXT, FormFieldType.TEXT,
required=False, required=False,
key="HB_bemerkungsfeld", key="HB_bemerkungsfeld",
tooltip="z.B. Promotionen oder den Studiengang angeben",
), ),
] ]
INITREC_WORK_EXPERIENCE = [ _INITREC_WORK_EXPERIENCE = [
FormField( FormField(
"Branche", "Branche",
FormFieldType.DROPDOWN, FormFieldType.DROPDOWN,
@@ -816,7 +806,6 @@ INITREC_WORK_EXPERIENCE = [
("Teilzeit", None), ("Teilzeit", None),
("Sonstiges", None), ("Sonstiges", None),
], ],
tooltip="Minijob, Praktikum, Wehrdienst, soziale Dienste",
), ),
FormField( FormField(
"Bemerkungsfeld", "Bemerkungsfeld",
@@ -827,7 +816,7 @@ INITREC_WORK_EXPERIENCE = [
] ]
INITREC_LANGUAGES = [ _INITREC_LANGUAGES = [
FormField( FormField(
"Sprache", "Sprache",
FormFieldType.TEXT, FormFieldType.TEXT,
@@ -869,7 +858,7 @@ INITREC_LANGUAGES = [
), ),
FormField( FormField(
"Datum des Nachweises", "Datum des Nachweises",
FormFieldType.DATE, FormFieldType.DATE_FLEXIBLE,
required=False, required=False,
key="SP_datum_nachweis", key="SP_datum_nachweis",
), ),
@@ -881,120 +870,16 @@ INITREC_LANGUAGES = [
INITREC_COMP = [ 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( FormField(
"Suche", "Suche",
FormFieldType.CUSTOM, FormFieldType.CUSTOM,
custom_widget="grunderfassung_suche", custom_widget="grunderfassung_unternehmen_suche",
key="Partnersuche", key="Partnersuche",
children=INITREC_COMP_SEARCH_HEAD, 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",
), ),
] ]
INITREC_PERSON = [ INITREC_METADATEN = [
FormField( FormField(
"Ersteintrag Datum", "Ersteintrag Datum",
FormFieldType.TEXT_DATETIME, FormFieldType.TEXT_DATETIME,
@@ -1017,10 +902,11 @@ INITREC_PERSON = [
required=False, required=False,
key="Metadaten_nutzer", key="Metadaten_nutzer",
readonly=True, readonly=True,
ignore_get_data=False,
), ),
FormField( FormField(
"Wiedereintrittsdatum", "Wiedereintrittsdatum",
FormFieldType.DATE, FormFieldType.DATE_FLEXIBLE,
required=False, required=False,
key="Metadaten_wiedereintrittsdatum", key="Metadaten_wiedereintrittsdatum",
readonly=False, readonly=False,
@@ -1037,6 +923,9 @@ INITREC_PERSON = [
required=False, required=False,
key="Grunderfassung_notiz", key="Grunderfassung_notiz",
), ),
]
INITREC_PERSON = [
FormField( FormField(
"Status && Projektrelevanz", "Status && Projektrelevanz",
FormFieldType.GROUP, FormFieldType.GROUP,
@@ -1049,7 +938,7 @@ INITREC_PERSON = [
trigger_value="ja", trigger_value="ja",
children=[ children=[
FormField( FormField(
"Relevanz", "Relevant perspektivERZ",
FormFieldType.DROPDOWN, FormFieldType.DROPDOWN,
required=True, required=True,
options=[("ja", None), ("nein", None)], options=[("ja", None), ("nein", None)],
@@ -1068,42 +957,42 @@ INITREC_PERSON = [
"Daten Kontaktperson", "Daten Kontaktperson",
FormFieldType.GROUP, FormFieldType.GROUP,
key="Kontaktperson", key="Kontaktperson",
children=INITREC_CONTACT_PERSON, children=_INITREC_CONTACT_PERSON,
), ),
FormField( FormField(
"Stammdaten", "Stammdaten",
FormFieldType.GROUP, FormFieldType.GROUP,
key="Stammdaten", key="Stammdaten",
children=INITREC_MASTER_DATA, children=_INITREC_MASTER_DATA,
), ),
FormField( FormField(
"Weitere Informationen", "Weitere Informationen",
FormFieldType.GROUP, FormFieldType.GROUP,
key="WeitereInfos", key="WeitereInfos",
children=INITREC_ADDITIONAL_DATA, children=_INITREC_ADDITIONAL_DATA,
), ),
FormField( FormField(
"Schulbildung", "Schulbildung",
FormFieldType.DYNAMIC_LIST, FormFieldType.DYNAMIC_LIST,
children=INITREC_SCHOOL, children=_INITREC_SCHOOL,
key="Schulbildung", key="Schulbildung",
), ),
FormField( FormField(
"Studium/Ausbildung", "Studium/Ausbildung",
FormFieldType.DYNAMIC_LIST, FormFieldType.DYNAMIC_LIST,
children=INITREC_HIGHER_EDUCATION, children=_INITREC_HIGHER_EDUCATION,
key="HoehereBildung", key="HoehereBildung",
), ),
FormField( FormField(
"Arbeitserfahrung", "Arbeitserfahrung",
FormFieldType.DYNAMIC_LIST, FormFieldType.DYNAMIC_LIST,
children=INITREC_WORK_EXPERIENCE, children=_INITREC_WORK_EXPERIENCE,
key="Arbeitserfahrung", key="Arbeitserfahrung",
), ),
FormField( FormField(
"Sprachkenntnisse", "Sprachkenntnisse",
FormFieldType.DYNAMIC_LIST, FormFieldType.DYNAMIC_LIST,
children=INITREC_LANGUAGES, children=_INITREC_LANGUAGES,
key="Sprachkenntnisse", key="Sprachkenntnisse",
), ),
] ]
+4826 -883
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -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()
@@ -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(
"📁 <b>Dateien hierher ziehen</b> oder <u>klicken</u> 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("🔒 <b>Dateiupload deaktiviert</b> (Formular gesperrt)")
self.setCursor(Qt.CursorShape.ForbiddenCursor)
else:
self.label.setText(
"📁 <b>Dateien hierher ziehen</b> oder <u>klicken</u> 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"<b>{self.STATE.dateiname_original}</b>")
self.meta_label.setText(
(
f"<span style='color: #666666;'>{self.file_size_display} • Hinzugefügt: "
f"{created_at_string}</span>"
)
)
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("<b>📎 Dokumente & Anhänge (0)</b>")
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"<b>📎 Dokumente & Anhänge ({count})</b>")
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()
+24 -6
View File
@@ -30,13 +30,31 @@ logger_base.setLevel(logging.DEBUG)
# ** GUI # ** GUI
logger_gui = logger_base.getChild("gui") logger_gui = logger_base.getChild("gui")
logger_gui.setLevel(logging.DEBUG) logger_gui.setLevel(logging.DEBUG)
logger_search_widget = logger_gui.getChild("search_widget") 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 = logger_gui.getChild("get_data")
logger_get_data.setLevel(logging.DEBUG) logger_get_data.setLevel(logging.WARNING)
logger_auto_form = logger_gui.getChild("get_data_auto_form")
logger_auto_form.setLevel(logging.DEBUG) 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 # ** Backend
logger_back = logger_base.getChild("backend") logger_backend = logger_base.getChild("backend")
logger_back.setLevel(logging.DEBUG) logger_backend.setLevel(logging.WARNING)
logger_initrec_docs = logger_base.getChild("initrec_docs")
logger_initrec_docs.setLevel(logging.DEBUG)
+269 -1
View File
@@ -1,9 +1,277 @@
from __future__ import annotations 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: if TYPE_CHECKING:
import polars as pl 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"]] 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