87 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
foefl abb4c67b67 doc version history 2026-06-17 17:34:27 +02:00
foefl fdf44454a0 bump version 2026-06-17 17:31:38 +02:00
foefl 5ce911f8cd fix for alembic pipeline 2026-06-17 17:31:32 +02:00
foefl 4364d8c0f9 remove unused imports 2026-06-17 17:31:09 +02:00
foefl 696357138a adapt logging behaviour 2026-06-17 17:30:52 +02:00
foefl 389fe2e159 add database migration tooling with alembic 2026-06-17 16:55:24 +02:00
foefl decfdbffd1 small refactoring with more precise declarations 2026-06-17 16:54:54 +02:00
foefl f24c4f0ff7 robust .env loading 2026-06-17 16:19:44 +02:00
foefl 1f75801fc5 add initial recording of individual persons with changed database interaction 2026-06-17 09:46:04 +02:00
foefl 4a1d5d4647 change database structure for initial recording of individual persons 2026-06-17 09:45:26 +02:00
foefl 44402af0b7 major refactoring 2026-06-17 09:44:06 +02:00
foefl 15af78900f fix list 2026-05-29 12:03:10 +02:00
44 changed files with 11008 additions and 2390 deletions
+10 -2
View File
@@ -16,10 +16,18 @@ Die Anwendung besteht aus einem Ordner, der alle relevanten Dateien zu ihrer Aus
## Verwendung
In der aktuellen Version ist die Grunderfassung für Unternehmen implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann die Grunderfassung für Unternehmen gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz unten gespeichert oder zurückgesetzt werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen.
### Grunderfassung
In der aktuellen Version ist die Grunderfassung implementiert. Formularfelder und grundlegende Optik orientieren sich an den bereitgestellten Anforderungsdokumenten inkl. etwaiger Mock-Ups. Zur Anlage eines neuen Eintrags ist in der Übersicht oben die Schaltfläche "Neu" zu wählen. Anschließend kann der Typ der Grunderfassung gewählt werden. Daraufhin öffnet sich eine Formularübersicht. Pflichtfelder sind mit einem Sternchen "*" gekennzeichnet. Sollten Pflichtfelder beim Speichern fehlen, so werden diese in einem Hinweisfenster benannt und im Formular selbst rot hervorgehoben. Das Formular kann mit den Knöpfen ganz oben gespeichert oder zurückgesetzt werden. Hierfür muss vorther die Bearbeitung aktiviert werden. Darüber hinaus sind Tastenkürzel für beide Aktionen hinterlegt: "Strg + s" für das Speichern und "Strg + z" für das Zurücksetzen. Einträge können auch gelöscht werden.
Sind alle Felder gefüllt, werden im Hintergrund die Daten nach dem Auslösen des Speichervorgangs nochmals validiert. Beispielsweise werden erfasste E-Mail-Adressen auf Gültigkeit geprüft. Etwaige Fehler werden dem Nutzer ebenfalls angezeigt.
Nach der erfolgreichen Speicherung kann über die Schaltfläche im oberen Bereich "zurück zur Übersicht" auf die Hauptseite gewechselt werden. Dort sollte nun der neu angelegte Eintrag sichtbar sein. Die Übersicht ist absteigend nach dem Datum der letzten Änderung sortiert. Ein eben gespeicherter Eintrag sollte sich demzufolge ganz oben in der Übersicht befinden. Mit einem Klick auf einen Eintrag in der Übersicht wird erneut das Formular zur Grunderfassung mit den hinterlegten Daten geladen und angezeigt. Es können Änderungen vorgenommen und gespeichert werden.
*Die Grunderfassung für Individualpersonen ist aktuell nur als Dummy verfügbar. Bei einem Klick auf die entsprechende Schaltfläche passiert nichts.*
### Unternehmesprofil
Wird eine Grunderfassung mit Unternehmen durchgeführt, so kann für dieses Unternehmen auf das Unternehmensprofil über die Schaltfläche im oberen Bereich gewechselt werden. Aktuell beherbergt dieses Profil die Stammdaten als auch die Verwaltung von Pauschal- und Indivualberatungen.
### Beratungen
Über die Startseite und die Schaltfläche "Neu" sowie die Auswahl im Unternehmesprofil kann ein Beratungsvorgang angelegt werden. Es kann zwischen Pauschal- und Individualberatungen ausgewählt werden. Je nach Kontext ist die Beratung bereits mit Entitäten verknüpft. Ausstehende Verknüpfungen werden nach dem Speichern der Beratung dem Nutzer überein Banner mitgeteilt. Er hat dann die Möglichkeit, die Verknüpfung von Unternehmen und/oder Personen vorzunehmen.
+148
View File
@@ -0,0 +1,148 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///data/db/wce_grunderfassung.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+83
View File
@@ -0,0 +1,83 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from wce_crm import constants, db
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = db.MD_MAIN
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
config.set_main_option("sqlalchemy.url", f"sqlite:///{constants.Config.DB_PATH_MAIN}")
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -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 ###
@@ -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
+5 -3
View File
@@ -1,4 +1,6 @@
DOPT_STOP_FOLDER_NAME=python
DOPT_DB_CRM=data/wce_crm.db
DOPT_DB_MAIN=data/wce_grunderfassung.db
DOPT_PATH_LOGGING=data/logs
DOPT_DB_CRM=data/db/wce_crm.db
DOPT_DB_MAIN=data/db/wce_grunderfassung.db
DOPT_PATH_LOGGING=data/logs
DOPT_PATH_FILE_STORAGE=data/files
DOPT_ALEMBIC_BASE=python/alembic
+34
View File
@@ -1,5 +1,39 @@
# Versionshistorie (Changelog)
## 31.07.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260731)
- Umsetzung der folgenden Anforderungen (IDs):
- 1000
- 1002
- 1003
- 1004
- 1005
- 1006
- 1007
- 1008
- 1009
- 1011
- 1012
- 1013
- 1014
- Grunderfassung komplett überarbeitet:
- mehr Übersicht durch aufklappbare Bereiche
- Hinzufügen und Verwalten von Dokumenten
- weitere gemäß Anforderungsliste
- Unternehmensprofil:
- hinzugefügt
- Möglichkeit zur Anlage von Beratungsvorgängen
- Beratungsvorgänge:
- Anlegen über Startseite "Neu" und über Unternehmensprofil
- nachträgliche Verknüpfung mit Unternehmen/Personen (kontextabhängig)
- Hinzufügen/Verwalten mehrerer Sessions
- Platzhalter für Verlinkung in CRM-Matrix (mehrere Elemente sollen später ausgewählt werden)
## 17.06.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260617)
- Grunderfassung für Individualpersonen hinzugefügt
- Löschen von Einträgen möglich (Option/Schaltfläche neben dem Speichern und Zurücksetzen: Tastenkombination ``Strg + L``)
## 29.05.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260529)
- initiale Version für erste Feedback-Schleife
Generated
+39 -22
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev", "lint", "nb", "tests"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:25812ee6ba42033e1341c1799716828d8582092826c1d0889ea775a0f5c548a2"
content_hash = "sha256:bde2f66706034b9c34c6794bf192b44db6e116a287938d88c90c751a2666bdf7"
[[metadata.targets]]
requires_python = ">=3.11,<3.14"
@@ -152,6 +152,23 @@ files = [
{file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"},
]
[[package]]
name = "alembic"
version = "1.18.4"
requires_python = ">=3.10"
summary = "A database migration tool for SQLAlchemy."
groups = ["default"]
dependencies = [
"Mako",
"SQLAlchemy>=1.4.23",
"tomli; python_version < \"3.11\"",
"typing-extensions>=4.12",
]
files = [
{file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"},
{file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"},
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -1845,7 +1862,7 @@ name = "mako"
version = "1.3.11"
requires_python = ">=3.8"
summary = "A super-fast templating language that borrows the best ideas from the existing templating languages."
groups = ["dev"]
groups = ["default", "dev"]
dependencies = [
"MarkupSafe>=0.9.2",
]
@@ -1895,7 +1912,7 @@ name = "markupsafe"
version = "3.0.3"
requires_python = ">=3.9"
summary = "Safely add untrusted strings to HTML/XML markup."
groups = ["dev", "nb"]
groups = ["default", "dev", "nb"]
files = [
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
@@ -3554,29 +3571,29 @@ files = [
[[package]]
name = "ruff"
version = "0.15.10"
version = "0.15.21"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["lint"]
files = [
{file = "ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f"},
{file = "ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e"},
{file = "ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0"},
{file = "ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609"},
{file = "ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07"},
{file = "ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48"},
{file = "ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5"},
{file = "ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed"},
{file = "ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188"},
{file = "ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e"},
{file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"},
{file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"},
{file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"},
{file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"},
{file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"},
{file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"},
{file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"},
{file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"},
]
[[package]]
+19
View File
@@ -0,0 +1,19 @@
# %%
import sqlalchemy as sql
from wce_crm import db
# %%
stmt = sql.text(
"ALTER TABLE grunderfassung_unternehmen ADD COLUMN Metadaten_wiedereintrittsdatum DATE"
)
with db.ENGINE.begin() as conn:
conn.execute(stmt)
# %%
stmt = sql.text("ALTER TABLE grunderfassung_unternehmen RENAME TO grunderfassung")
with db.ENGINE.begin() as conn:
conn.execute(stmt)
# %%
+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
from collections import defaultdict
from collections.abc import Sequence
from pprint import pprint
from pprint import pformat, pprint
from typing import Annotated, Any
import babel
import polars as pl
import sqlalchemy as sql
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from PySide6.QtCore import QDate, Qt
from wce_crm import constants, db
from wce_crm.backend import backend
from wce_crm.types import EntityIds, MainPageEntry, RecordingType
# %%
int("test")
# backend.page_consulting_linking_companies(None)
# backend.page_consulting_linking_persons(3)
ret = backend.main_page_get_entries()
pprint(ret)
# %%
with db.ENGINE.connect() as conn:
q_relevant_entries = (
sa.select(
db.t_grunderfassung_personen.c.pers_id,
db.t_grunderfassung_personen.c.aktualisiert,
db.t_zuordnung_personen_unternehmen.c.id.label("link_id"),
db.t_zuordnung_personen_unternehmen.c.un_id,
db.t_zuordnung_personen_unternehmen.c.an_id,
db.t_grunderfassung_personen.c.Stammdaten__name,
db.t_grunderfassung_personen.c.Stammdaten__vorname,
db.t_grunderfassung_personen.c.Stammdaten__ort,
)
.outerjoin(
db.t_zuordnung_personen_unternehmen,
db.t_grunderfassung_personen.c.pers_id
== db.t_zuordnung_personen_unternehmen.c.pers_id,
)
.order_by(db.t_grunderfassung_personen.c.Stammdaten__name)
).subquery("relevant_entries")
stmt = sa.select(
q_relevant_entries,
db.t_ext_crm_master_attach.c.ma_unternehmensname,
).outerjoin(
db.t_ext_crm_master_attach,
q_relevant_entries.c.un_id == db.t_ext_crm_master_attach.c.ma_id,
)
ret = conn.execute(stmt)
# results = list(ret.mappings())
# print(f"Results are:\n{pformat(results)}")
main_page_entries: list[MainPageEntry] = []
for entry in ret.mappings():
ids = EntityIds(
pers_id=entry["pers_id"],
link_id=entry["link_id"],
un_id=entry["un_id"],
an_id=entry["an_id"],
)
recording_type = RecordingType.WITHOUT_COMPANY
if ids.un_id or ids.link_id:
recording_type = RecordingType.WITH_COMPANY
first_name = entry["Stammdaten__vorname"]
last_name = entry["Stammdaten__name"]
location_city = entry["Stammdaten__ort"]
person_name = f"{first_name} {last_name}"
if location_city:
person_name += f" (Ort: {location_city})"
company_name = entry["ma_unternehmensname"]
display_name = person_name
if recording_type is RecordingType.WITH_COMPANY:
display_name = company_name
main_page_entries.append(
MainPageEntry(
ids=ids,
display_name=display_name,
dt_updated=entry["aktualisiert"],
recording_type=recording_type,
)
)
main_page_entries.sort(key=lambda x: x.dt_updated, reverse=True)
pprint(main_page_entries)
# %%
# %%
entry = results[0]
ids = EntityIds(
pers_id=entry["pers_id"],
link_id=entry["link_id"],
un_id=entry["un_id"],
an_id=entry["an_id"],
)
recording_type = RecordingType.WITHOUT_COMPANY
if ids.un_id or ids.link_id:
recording_type = RecordingType.WITH_COMPANY
first_name = entry["Stammdaten__vorname"]
last_name = entry["Stammdaten__name"]
location_city = entry["Stammdaten__ort"]
person_name = f"{first_name} {last_name}"
if location_city:
person_name += f" (Ort: {location_city})"
company_name = entry["ma_unternehmensname"]
display_name = person_name
if recording_type is RecordingType.WITH_COMPANY:
display_name = company_name
main_page_entry = MainPageEntry(
ids=ids,
display_name=display_name,
dt_updated=entry["aktualisiert"],
recording_type=recording_type,
)
# %%
pprint(main_page_entry)
# %%
# %%
@dc.dataclass(slots=True, kw_only=True)
class LinkingPerson:
pers_id: int
display_name: str
un_ids: set[int]
with db.ENGINE.connect() as conn:
# if un_id None: list all persons
# else: list just the persons associated with the company
stmt = (
sa.select(
db.t_grunderfassung_personen.c.pers_id,
db.t_zuordnung_personen_unternehmen.c.un_id,
db.t_grunderfassung_personen.c.Stammdaten__name,
db.t_grunderfassung_personen.c.Stammdaten__vorname,
db.t_grunderfassung_personen.c.Stammdaten__ort,
)
.outerjoin(
db.t_zuordnung_personen_unternehmen,
db.t_grunderfassung_personen.c.pers_id
== db.t_zuordnung_personen_unternehmen.c.pers_id,
)
.order_by(db.t_grunderfassung_personen.c.Stammdaten__name)
)
# if un_id is not None:
# person_select = person_select.select_from(
# db.t_grunderfassung_personen.join(
# db.t_zuordnung_personen_unternehmen,
# db.t_grunderfassung_personen.c.pers_id
# == db.t_zuordnung_personen_unternehmen.c.pers_id,
# )
# ).where(db.t_zuordnung_personen_unternehmen.c.un_id == un_id)
ret = conn.execute(stmt)
persons_map: dict[str, Any] = {}
for row in ret:
p_id = row.pers_id
if p_id not in persons_map:
first_name = row.Stammdaten__vorname
last_name = row.Stammdaten__name
location_city = row.Stammdaten__ort
person_name = f"{first_name} {last_name}"
if location_city:
person_name += f" (Ort: {location_city})"
persons_map[p_id] = {
"pers_id": p_id,
"display_name": person_name,
"un_ids": set(),
}
# Falls verknüpft, ID zur Menge hinzufügen
if row.un_id is not None:
persons_map[p_id]["un_ids"].add(row.un_id)
person_names = [LinkingPerson(**data) for data in persons_map.values()]
# person_names = ret.mappings().all()
# if not person_names:
# print(f"No persons found for 'un_id': {un_id}")
print(f"[Page Consulting -- Linking] Retrieved person names:\n{pformat(person_names)}")
# %%
dic = {
"t1": "test1",
"t2": "test2",
"t3": "test3",
"t4": "test4",
}
filter_keys = frozenset(("t3", "t2"))
dic_in, dic_out = backend.split_dict(dic, filter_keys)
print(f"{dic_in=}")
print(f"{dic_out=}")
# %%
dic_update = {
"t5": "new",
"t6": "new_2",
}
dic_updated = backend.dic_deep_update(dic, dic_update)
print(f"{dic=}")
print(f"{dic_update=}")
print(f"{dic_updated=}")
# %%
raw_ent_ids = EntityIds()
db_ent_ids = EntityIds(un_id=3, an_id=2)
print(f"{raw_ent_ids=}\n{db_ent_ids=}")
print(f"{raw_ent_ids.empty()=}")
print(f"{raw_ent_ids.valid()=}")
# %%
raw_ent_ids.update(db_ent_ids)
print(f"{raw_ent_ids=}")
db_ent_ids = EntityIds(un_id=None, an_id=2, link_id=1)
raw_ent_ids.update(db_ent_ids)
print(f"{raw_ent_ids=}")
print(f"{raw_ent_ids.empty()=}")
print(f"{raw_ent_ids.valid()=}")
raw_ent_ids.update(EntityIds())
print(f"{raw_ent_ids=}")
print(f"{raw_ent_ids.empty()=}")
print(f"{raw_ent_ids.valid()=}")
# %%
raw_ent_ids.update(EntityIds(un_id=None, pers_id=2))
print(f"{raw_ent_ids=}")
print(f"{raw_ent_ids.empty()=}")
print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}")
raw_ent_ids.update(EntityIds(un_id=None, an_id=2, link_id=3, pers_id=2))
print(f"{raw_ent_ids=}")
print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}")
raw_ent_ids.update(EntityIds(un_id=2, pers_id=None, an_id=2, link_id=3))
print(f"{raw_ent_ids=}")
print(f"{raw_ent_ids.empty(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.empty(RecordingType.WITHOUT_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITH_COMPANY)=}")
print(f"{raw_ent_ids.valid(RecordingType.WITHOUT_COMPANY)=}")
# %%
db_path = constants.Config.DB_PATH_MAIN
crm_path = constants.Config.DB_PATH_CRM
crm_path = constants.Config.DB_PATH_KONTAKTLISTE
engine = sql.create_engine(f"sqlite:///{str(db_path)}")
engine_crm = sql.create_engine(f"sqlite:///{str(crm_path)}")
# %%
stmt = sql.select(
db.grunderfassung_unternehmen.c.erfassung_id,
db.grunderfassung_unternehmen.c.Partnersuche__un_suche,
)
with engine.connect() as conn:
res = conn.execute(stmt)
for r in res:
print(r)
# %%
# %%
backend.get_company_list()
backend._main_page_get_company_list() # type: ignore
# %%
@@ -218,12 +456,12 @@ class FlatBaseModel(BaseModel):
return final_nested_data
def to_db(self) -> Dict[str, Any]:
def to_db(self) -> dict[str, Any]:
"""Ausgang für die DB: Flach, Listen sind JSON-Strings."""
nested = super().model_dump()
return self.__flatten_dict(nested, serialize_lists=True)
def to_gui(self) -> Dict[str, Any]:
def to_gui(self) -> dict[str, Any]:
"""Ausgang für die GUI: Flach, aber Listen bleiben Python-Listen."""
nested = super().model_dump()
return self.__flatten_dict(nested, serialize_lists=False)
+12 -4
View File
@@ -1,11 +1,11 @@
[project]
name = "wce-crm"
version = "0.1.1dev7"
version = "0.1.1dev11"
description = "GUI for CRM of NAFKA project with WCE"
authors = [
{name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"},
]
dependencies = ["pyside6>=6.11.0", "sqlalchemy[asyncio]>=2.0.50", "polars>=1.40.1", "dopt-basics>=0.2.6", "pydantic[email]>=2.13.4", "babel>=2.18.0", "python-dotenv>=1.2.2"]
dependencies = ["pyside6>=6.11.0", "sqlalchemy[asyncio]>=2.0.50", "polars>=1.40.1", "dopt-basics>=0.2.6", "pydantic[email]>=2.13.4", "babel>=2.18.0", "python-dotenv>=1.2.2", "alembic>=1.18.4"]
requires-python = "<3.14,>=3.11"
readme = "README.md"
license = {text = "LicenseRef-Proprietary"}
@@ -14,6 +14,14 @@ license = {text = "LicenseRef-Proprietary"}
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pyright]
typeCheckingMode = "basic"
pythonVersion = "3.11"
reportUnusedImport = "none"
reportUnusedVariable = "warning"
reportUnusedClass = "warning"
reportUnusedFunction = "warning"
reportPrivateUsage = "error"
[tool.ruff]
line-length = 94
@@ -26,7 +34,7 @@ quote-style = "double"
skip-magic-trailing-comma = false
[tool.ruff.lint]
select = ["E", "F", "I"]
select = ["E", "F", "I", "SLF"]
[tool.ruff.lint.isort]
extra-standard-library = ["typing_extensions"]
@@ -71,7 +79,7 @@ directory = "reports/coverage"
[tool.bumpversion]
current_version = "0.1.1dev7"
current_version = "0.1.1dev11"
parse = """(?x)
(?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\.
+5
View File
@@ -0,0 +1,5 @@
param(
[string]$Message
)
pdm run alembic revision --autogenerate -m $Message
+55 -10
View File
@@ -8,6 +8,28 @@ $ENV_PATH = 'B:\deployments\WCE-NAFKA\dopt_nafka_wce-crm'
$PY_PATH = Join-Path -Path $ENV_PATH -ChildPath 'python'
$SRC_PATH = (Get-Location).Path
function create_folder {
param (
[string]$base_path,
[string]$folder_name,
[switch]$recreate
)
$target_path = Join-Path -Path $base_path -ChildPath $folder_name
$target_path_exists = Test-Path -Path $target_path
if (-not $target_path_exists){
Write-Output "[PWSH] Folder >$folder_name< not existing. Create..."
New-Item -Path $target_path -ItemType Directory
}
elseif ($target_path_exists -and $recreate){
Write-Output "[PWSH] Folder >$folder_name< exists, but should be recreated..."
Remove-Item -Path $target_path -Recurse -Force
New-Item -Path $target_path -ItemType Directory
}
else {
Write-Output "Folder >$folder_name< already exists."
}
}
Write-Output "Build Pipeline for d-opt WCE/NAFKA project"
@@ -23,15 +45,10 @@ Write-Output "Deleted outstanding artifacts successfully"
Write-Output "Create data folder..."
$pattern = "dopt_nafka_wce-crm_v*"
$data_folder = Join-Path -Path $ENV_PATH -ChildPath 'data'
if (-not (Test-Path -Path $data_folder)){
Write-Output "[PWSH] Data path not existing. Create..."
New-Item -Path $data_folder -ItemType Directory
}
else {
Write-Output "Data path already exists."
}
create_folder -base_path $ENV_PATH -folder_name 'data' -recreate
create_folder -base_path $ENV_PATH -folder_name 'data/db'
create_folder -base_path $ENV_PATH -folder_name 'data/files'
Write-Output "Building package..."
@@ -71,10 +88,10 @@ if ($? -eq $false){
}
Write-Output "Successfully installed package"
# copy database file
# copy database files
Write-Output "Copying database files..."
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/db/wce_grunderfassung.db'
$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data'
$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/db'
Copy-Item -Path $copy_file -Destination $dest_file -Force
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying database 'Grunderfassung'"
@@ -88,6 +105,34 @@ if ($? -eq $false){
}
Write-Output "Copied database files successfully"
# copy content files
Write-Output "Copying content files (documents)..."
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'data/files'
$dest_file = Join-Path -Path $ENV_PATH -ChildPath 'data/'
Copy-Item -Path $copy_file -Destination $dest_file -Force -Recurse
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying documents"
Exit
}
Write-Output "Copied content files (documents) successfully"
# copy alembic files
Write-Output "Copying alembic files..."
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic'
$dest_file = Join-Path -Path $PY_PATH -ChildPath 'alembic'
create_folder -base_path $PY_PATH -folder_name 'alembic'
Copy-Item -Path $copy_file -Destination $dest_file -Force -Recurse
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying the alembic folder"
Exit
}
$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic.ini'
Copy-Item -Path $copy_file -Destination $dest_file -Force
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying the alembic INI file"
Exit
}
# copy .env file
Write-Output "Copying ENV file..."
$env_file = Join-Path -Path $SRC_PATH -ChildPath 'deployment/.env'
+7 -5
View File
@@ -1,7 +1,9 @@
# List of environment variables
DOPT_DEVELOPMENT: flag which signals that the current environment is in development mode
DOPT_STOP_FOLDER_NAME: stop folder to find 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_PATH_LOGGING: path to logging folder, relative to base path
- DOPT_DEVELOPMENT: flag which signals that the current environment is in development mode
- DOPT_STOP_FOLDER_NAME: stop folder to find 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_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
+6 -1
View File
@@ -1,5 +1,6 @@
import os
import sys
from pathlib import Path
import dotenv
@@ -7,4 +8,8 @@ if sys.stdout is None or sys.stderr is None:
sys.stdout = open(os.devnull, "w")
sys.stderr = open(os.devnull, "w")
dotenv.load_dotenv()
deploy_env_pth = Path(sys.executable).parent / ".env"
if deploy_env_pth.exists():
dotenv.load_dotenv(dotenv_path=deploy_env_pth)
else:
dotenv.load_dotenv()
+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)
+734 -111
View File
@@ -1,66 +1,59 @@
from __future__ import annotations
import dataclasses as dc
import copy
import datetime
from typing import Any, TypedDict, cast
from collections.abc import Container, Mapping
from pprint import pformat
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
import polars as pl
import sqlalchemy as sql
import sqlalchemy as sa
from dopt_basics.result_pattern import wrap_result
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from wce_crm import db
from wce_crm.logging import logger_back as logger
from wce_crm.backend.document_service import (
DocumentRepository,
DocumentService,
FileStorageService,
)
from wce_crm.constants import TIMEZONE_CEST, Config
from wce_crm.data_models import (
FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN,
FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN,
FIELD_DB_SPLIT_METADATEN,
Beratungsgespraech_Einzelgespraech,
Beratungsgespraech_Vorgang,
InitRec,
Page_InitRec_Form_Data,
)
from wce_crm.logging import logger_backend as logger
from wce_crm.types import (
CompanyInfo,
CompanyProfileConsultationEntry,
CompanyProfileConsultations,
ConsultingType,
ContactPersonInfo,
EntityIds,
EntityType,
LinkingConsultationsEntryCompany,
LinkingConsultationsEntryPerson,
MainPageEntry,
RecordingType,
)
if TYPE_CHECKING:
from dopt_basics.datastructures import DualDict
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
from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId
K = TypeVar("K")
V = TypeVar("V")
class ContactPersonInfo(TypedDict):
an_id: str
ma_id: str
wce_id: str
st_id: str
an_sachgebiet: str
an_anrede: str
an_titel: str
an_nachname: str
an_vorname: str
an_position: str
an_mail: str
an_festnetz: str
an_mobil: str
an_faxnummer: str
an_hauptansprechpartner: str
an_anrede_anschrift: str
an_bemerkung: str
an_aktualisierung_datum: str
an_aktualisierung_nutzer: str
an_letztes_kontaktdatum: str
an_ersteintrag_datum: str
an_archiviert: str
INITREC_DOC_SERVICE: Final[DocumentService] = DocumentService(
repo=DocumentRepository(file_storage_dir=Config.PATH_FILE_STORAGE),
storage=FileStorageService(storage_dir=Config.PATH_FILE_STORAGE),
)
def _transform_for_gui_output(
@@ -82,7 +75,7 @@ def _transform_for_gui_output(
return q.collect()
def comp_search_choices() -> tuple[tuple[str, int], ...]:
def initrec_comp_search_choices() -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
logger.debug("[Call backend] comp_search_choices")
q = db.DF_CRM_MASTER.lazy()
@@ -97,8 +90,9 @@ def comp_search_choices() -> tuple[tuple[str, int], ...]:
return tuple(zip(df["dedupl"], df["ma_id"]))
def comp_search_get_info(
ma_id: int,
# TODO change to direct SQL interaction
def external_company_get_info(
ma_id: ExtMaId,
) -> CompanyInfo:
logger.debug("[Call backend] comp_search_get_info")
df = db.DF_CRM_MASTER.filter(pl.col.ma_id == ma_id)
@@ -109,8 +103,8 @@ def comp_search_get_info(
return cast(CompanyInfo, df.row(0, named=True))
def contact_person_search_choices(
ma_id: int | None,
def initrec_comp_contact_person_search_choices(
ma_id: ExtMaId | None,
use_both_names: bool,
) -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
@@ -137,8 +131,8 @@ def contact_person_search_choices(
return tuple(zip(df["dedupl"], df["an_id"]))
def contact_person_search_get_info(
an_id: int,
def initrec_comp_contact_person_search_get_info(
an_id: ExtAnId,
) -> ContactPersonInfo:
logger.debug("[Call backend] contact_person_search_get_info")
df = db.DF_CONTACT_PERSON.filter(pl.col.an_id == an_id)
@@ -149,76 +143,705 @@ def contact_person_search_get_info(
return cast(ContactPersonInfo, df.row(0, named=True))
def insert_initial_recording(
data: dict[str, Any],
) -> None:
logger.debug("[Call backend] insert_initial_recording")
stmt = db.grunderfassung_unternehmen.insert().values(data)
with db.ENGINE.begin() as conn:
conn.execute(stmt)
# // internals
def split_dict(
dic: dict[K, V],
split_keys: Container[K],
) -> tuple[dict[K, V], dict[K, V]]:
keys_in: dict[K, V] = {}
keys_out = copy.deepcopy(dic)
for check_key in dic:
if check_key in split_keys:
keys_in[check_key] = keys_out.pop(check_key)
return keys_in, keys_out
def update_initial_recording(
id_: int,
data: dict[str, Any],
) -> None:
logger.debug("[Call backend] update_initial_recording")
stmt = (
db.grunderfassung_unternehmen.update()
.where(db.grunderfassung_unternehmen.c.erfassung_id == id_)
.values(data)
)
with db.ENGINE.begin() as conn:
conn.execute(stmt)
def dic_deep_update(
dic: dict[K, V],
data: Mapping[K, V],
) -> dict[K, V]:
dic_copy = copy.deepcopy(dic)
dic_copy.update(data)
return dic_copy
def get_initial_recording(
id_: int,
def _rename_db_data(
db_data: dict[str, Any],
mapping: DualDict,
) -> dict[str, Any]:
logger.debug("[Call backend] get_initial_recording")
stmt = db.grunderfassung_unternehmen.select().where(
db.grunderfassung_unternehmen.c.erfassung_id == id_
)
new_db_data = copy.deepcopy(db_data)
for k, v in db_data.items():
if k in mapping:
new_db_data[mapping[k]] = v
del new_db_data[k]
if k in mapping.inverted:
new_db_data[mapping.inverted[k]] = v
del new_db_data[k]
return new_db_data
def page_initrec_to_db(
data: InitRec,
) -> InitRec:
with db.ENGINE.begin() as conn:
# person
dump_data = dic_deep_update(data.form_data.person, data.form_data.metadata)
dump_data = _rename_db_data(dump_data, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN)
dump_data["geloescht"] = data.geloescht
if data.ids.pers_id is None:
logger.debug("[InitRec -- backend] Insert...")
stmt = sa.insert(db.t_grunderfassung_personen).returning(
db.t_grunderfassung_personen
)
stmt_compiled = str(stmt.compile(db.ENGINE))
logger.debug(
"[InitRec -- backend] Data to insert:\nStatement: %s\n%s",
stmt_compiled,
pformat(dump_data),
)
ret = conn.execute(stmt, dump_data)
from_db = ret.mappings().fetchall()
assert len(from_db) == 1, "expected excactly one returned row"
data_from_db = dict(from_db[0])
data_from_db = _rename_db_data(
data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN
)
data.ids.pers_id = data_from_db["pers_id"]
logger.debug("[AutoForm -- backend] Inserted InitRec Person successfully")
else:
logger.debug("[AutoForm -- backend] Update...")
stmt = (
db.t_grunderfassung_personen.update()
.where(db.t_grunderfassung_personen.c.pers_id == data.ids.pers_id)
.returning(db.t_grunderfassung_personen)
)
ret = conn.execute(stmt, dump_data)
from_db = ret.mappings().fetchall()
assert len(from_db) == 1, "expected excatly one returned row"
data_from_db = dict(from_db[0])
data_from_db = _rename_db_data(
data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN
)
# data.geloescht = data_from_db["geloescht"]
# data.form_data.person["Metadaten_aktualisierung"] = data_from_db[
# "Metadaten_aktualisierung"
# ]
logger.debug(
"[InitRec -- backend] Updated InitRec Person with ID %d successfully",
data.ids.pers_id,
)
data.geloescht = data_from_db["geloescht"]
del data_from_db["geloescht"]
in_filter, out_filter = split_dict(data_from_db, FIELD_DB_SPLIT_METADATEN)
data.form_data.metadata = in_filter
data.form_data.person = out_filter
# documents
assert data.ids.pers_id
data.docs = INITREC_DOC_SERVICE.to_db(conn, pers_id=data.ids.pers_id, docs=data.docs)
# company
if data.recording_type is RecordingType.WITH_COMPANY:
# insert into company table and add additional information
assert data.ids.un_id, "company ID not set"
assert data.ids.an_id, "contact person (an) ID not set"
assert data.ids.pers_id, "person ID not set"
stmt = (
sqlite_insert(db.t_unternehmen)
.values(un_id=data.ids.un_id, geloescht=None)
.on_conflict_do_nothing(index_elements=["un_id"])
)
conn.execute(stmt)
# additional information --> linking table
dump_data = _rename_db_data(
data.form_data.company, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN
)
logger.debug(
"[InitRec -- backend] Company - additional info: Dump Data = \n%s",
pformat(dump_data),
)
if "un_id" not in dump_data:
# only case when updated
dump_data["un_id"] = data.ids.un_id
dump_data["an_id"] = data.ids.an_id
stmt = sqlite_insert(db.t_zuordnung_personen_unternehmen).values(
**dump_data, pers_id=data.ids.pers_id
)
stmt = stmt.on_conflict_do_update(
index_elements=["un_id", "pers_id"],
index_where=sa.text("geloescht IS NULL"),
set_={
"an_id": stmt.excluded.an_id,
"Partnersuche__kontaktweg": stmt.excluded.Partnersuche__kontaktweg,
"aktualisiert": datetime.datetime.now(datetime.UTC),
"geloescht": data.geloescht,
},
).returning(db.t_zuordnung_personen_unternehmen.c.id)
ret = conn.execute(stmt)
from_db = ret.mappings().fetchall()
assert len(from_db) == 1, "expected excatly one returned row"
data_from_db = dict(from_db[0])
data_from_db = _rename_db_data(
data_from_db, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN
)
data.ids.link_id = data_from_db["id"]
return data
def page_initrec_from_db(
ids: EntityIds,
) -> InitRec:
logger.debug("[InitRec -- backend] Call database loading routine...")
recording_type = RecordingType.WITHOUT_COMPANY
if ids.link_id is not None or ids.valid(EntityType.COMPANY):
recording_type = RecordingType.WITH_COMPANY
data_company_table: dict[str, Any] = {}
if recording_type is RecordingType.WITH_COMPANY:
logger.debug("[InitRec -- backend] With company. Link ID: %s", ids.link_id)
if ids.link_id:
stmt = sa.select(
db.t_zuordnung_personen_unternehmen.c.pers_id,
db.t_zuordnung_personen_unternehmen.c.un_id,
db.t_zuordnung_personen_unternehmen.c.an_id,
db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg,
).where(
db.t_zuordnung_personen_unternehmen.c.id == ids.link_id,
)
else:
assert ids.un_id, "company ID not set"
assert ids.pers_id, "person ID not set"
stmt = sa.select(
db.t_zuordnung_personen_unternehmen.c.pers_id,
db.t_zuordnung_personen_unternehmen.c.un_id,
db.t_zuordnung_personen_unternehmen.c.an_id,
db.t_zuordnung_personen_unternehmen.c.Partnersuche__kontaktweg,
).where(
db.t_zuordnung_personen_unternehmen.c.pers_id == ids.pers_id,
db.t_zuordnung_personen_unternehmen.c.un_id == ids.un_id,
db.t_zuordnung_personen_unternehmen.c.geloescht.is_(None),
)
with db.ENGINE.connect() as conn:
ret = conn.execute(stmt)
from_db = ret.mappings().all()
assert len(from_db) == 1, "not excactly one company initial recording obtained"
data_company_table = dict(from_db[0])
ids.pers_id = data_company_table.pop("pers_id")
ids.un_id = data_company_table["un_id"]
ids.an_id = data_company_table["an_id"]
data_company_table = _rename_db_data(
data_company_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN
)
# person
assert ids.pers_id, "person ID not set"
logger.debug("[InitRec -- backend] Person... Person ID: %s", ids.pers_id)
stmt = db.t_grunderfassung_personen.select().where(
db.t_grunderfassung_personen.c.pers_id == ids.pers_id
)
with db.ENGINE.connect() as conn:
ret = conn.execute(stmt)
row = ret.fetchone()
if row is None:
raise KeyError(f"Database ID {id_} not found")
from_db = ret.mappings().all()
assert len(from_db) == 1, "not excactly one company initial recording obtained"
data_person_table = dict(from_db[0])
return row._asdict()
data_person_table = _rename_db_data(
data_person_table, FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN
)
geloescht = data_person_table["geloescht"]
del data_person_table["geloescht"]
in_filter, out_filter = split_dict(data_person_table, FIELD_DB_SPLIT_METADATEN)
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,
)
@dc.dataclass(slots=True)
class FrontpageCompany:
erfassung_id: int
ma_id: int
name: str
Metadaten_aktualisierung: datetime.datetime
@wrap_result(10)
def page_consulting_to_db(
consultation_data: Beratungsgespraech_Vorgang,
) -> Beratungsgespraech_Vorgang:
logger.debug("[Consulting Page] Call database saving routine...")
with db.ENGINE.begin() as conn:
if consultation_data.vorgang_id is None:
# insert new "Vorgang"
insert_data_process = consultation_data.model_dump(
exclude={"vorgang_id", "beratungen"}
)
logger.debug(
"[Consulting Page] Call insert 'Vorgang' with data:\n%s",
pformat(insert_data_process),
)
stmt = db.t_beratung_vorgang.insert()
ret = conn.execute(stmt, insert_data_process)
if ret.rowcount == 0:
raise IOError("Entry was not inserted correctly")
prim_keys = ret.inserted_primary_key
assert prim_keys
consultation_data.vorgang_id = cast("ConsId", prim_keys[0])
logger.debug("[Consulting Page] Inserted 'Vorgang' successfully")
else:
logger.debug(
"[Consulting Page] VorgangID already set. ID: %d. Update...",
consultation_data.vorgang_id,
)
update_data_process = consultation_data.model_dump(exclude={"beratungen"})
stmt = db.t_beratung_vorgang.update().where(
db.t_beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id
)
conn.execute(stmt, update_data_process)
rows_for_db_insert: list[dict[str, Any]] = []
rows_for_db_update: list[dict[str, Any]] = []
cons_sessions_inserted: list[Beratungsgespraech_Einzelgespraech] = []
for cons_session in consultation_data.beratungen:
cons_session.vorgang_id = consultation_data.vorgang_id
if cons_session.beratung_id is None:
row_data = cons_session.model_dump(exclude={"beratung_id"})
rows_for_db_insert.append(row_data)
cons_sessions_inserted.append(cons_session)
else:
row_data = cons_session.model_dump()
# new bind param to avoid name clashes
row_data["b_beratung_id"] = row_data["beratung_id"]
del row_data["beratung_id"]
rows_for_db_update.append(row_data)
if rows_for_db_update:
# ... update
logger.debug(
"[Consulting Page] Call update for sessions:\n%s", pformat(rows_for_db_update)
)
stmt = db.t_beratung_einzelberatung.update().where(
db.t_beratung_einzelberatung.c.beratung_id == sa.bindparam("b_beratung_id")
)
conn.execute(stmt, rows_for_db_update)
if rows_for_db_insert:
# ... insert
logger.debug(
"[Consulting Page] Call insert for sessions:\n%s", pformat(rows_for_db_insert)
)
stmt = db.t_beratung_einzelberatung.insert().returning(
db.t_beratung_einzelberatung.c.beratung_id
)
res = conn.execute(stmt, rows_for_db_insert)
new_cons_session_ids = cast(list[int], [row[0] for row in res.fetchall()])
assert len(cons_sessions_inserted) == len(new_cons_session_ids)
for cons_session, new_id in zip(cons_sessions_inserted, new_cons_session_ids):
cons_session.beratung_id = new_id
return consultation_data
def get_company_list() -> list[FrontpageCompany]:
logger.debug("[Call backend] get_company_list")
stmt = sql.select(
db.grunderfassung_unternehmen.c.erfassung_id,
db.grunderfassung_unternehmen.c.Partnersuche__un_suche,
db.grunderfassung_unternehmen.c.Metadaten_aktualisierung,
).order_by(db.grunderfassung_unternehmen.c.Metadaten_aktualisierung.desc())
@wrap_result(10)
def page_consulting_delete(
cons_id: ConsId,
) -> None:
timestamp_deleted = datetime.datetime.now(datetime.UTC)
dump_data: dict[str, Any] = {"geloescht": timestamp_deleted}
with db.ENGINE.begin() as conn:
stmt = db.t_beratung_vorgang.update().where(
db.t_beratung_vorgang.c.vorgang_id == cons_id
)
conn.execute(stmt, dump_data)
stmt = db.t_beratung_einzelberatung.update().where(
db.t_beratung_einzelberatung.c.vorgang_id == cons_id,
db.t_beratung_einzelberatung.c.geloescht.is_(None),
)
conn.execute(stmt, dump_data)
@wrap_result(11)
def page_consulting_from_db(
cons_id: ConsId,
) -> Beratungsgespraech_Vorgang:
# get "Vorgang" and all associated sessions to instantiate Pydantic data model
logger.debug("[Consulting Page] Call database reading routine...")
with db.ENGINE.connect() as conn:
# get the consultation process
stmt = db.t_beratung_vorgang.select().where(
db.t_beratung_vorgang.c.vorgang_id == cons_id
)
ret = conn.execute(stmt)
results = ret.mappings().all()
if not results:
raise KeyError(f"Database ID {cons_id} not found")
assert len(results) == 1, "more than one consulting process obtained"
consultation_data_db = results[0]
consultation_data = Beratungsgespraech_Vorgang(
vorgang_id=consultation_data_db["vorgang_id"],
un_id=consultation_data_db["un_id"],
pers_id=consultation_data_db["pers_id"],
titel=consultation_data_db["titel"],
beratungs_typ=consultation_data_db["beratungs_typ"],
erstellt=consultation_data_db["erstellt"],
aktualisiert=consultation_data_db["aktualisiert"],
geloescht=consultation_data_db["geloescht"],
beratungen=[],
)
if consultation_data.geloescht is not None:
logger.info(
(
"[Consulting Page] Obtained entry for ID=%d. This entry was marked "
"as deleted."
),
cons_id,
)
# get all consultation sessions of this process, only the ones
# which are not marked as deleted
stmt = db.t_beratung_einzelberatung.select().where(
db.t_beratung_einzelberatung.c.vorgang_id == cons_id,
db.t_beratung_einzelberatung.c.geloescht.is_(None),
)
ret = conn.execute(stmt)
cons_sessions = ret.mappings().all()
# empty results possible
if not cons_sessions:
logger.debug("[Consulting Page] No sessions, return directly...")
return consultation_data
for session in cons_sessions:
cons_session_pydantic = Beratungsgespraech_Einzelgespraech(
beratung_id=session["beratung_id"],
vorgang_id=session["vorgang_id"],
nutzer_id=session["nutzer_id"],
nutzer_name=session["nutzer_name"],
zeitstempel=session["zeitstempel"],
ansprechpartner=session["ansprechpartner"],
kommunikationsweg=session["kommunikationsweg"],
thema_crm_matrix=session["thema_crm_matrix"],
anmerkungen=session["anmerkungen"],
rueckmeldung=session["rueckmeldung"],
erstellt=session["erstellt"],
aktualisiert=session["aktualisiert"],
)
consultation_data.beratungen.append(cons_session_pydantic)
logger.debug("[Consulting Page] Returning with sessions attached...")
return consultation_data
# // consulting page interaction
def page_consulting_linking_companies(
un_id: RecId | None,
) -> list[LinkingConsultationsEntryCompany]:
with db.ENGINE.connect() as conn:
q_relevant_companies = sa.select(
db.t_unternehmen.c.un_id,
).where(
db.t_unternehmen.c.geloescht.is_(None),
)
if un_id is not None:
q_relevant_companies = q_relevant_companies.where(
db.t_unternehmen.c.un_id == un_id
)
q_relevant_companies = q_relevant_companies.subquery("relevant_companies")
stmt = sa.select(
q_relevant_companies.c.un_id,
db.t_ext_crm_master_attach.c.ma_unternehmensname,
).select_from(
q_relevant_companies.join(
db.t_ext_crm_master_attach,
q_relevant_companies.c.un_id == db.t_ext_crm_master_attach.c.ma_id,
)
)
ret = conn.execute(stmt)
company_names = ret.mappings().all()
if not company_names:
raise ValueError(f"No companies found for 'un_id': {un_id}")
logger.debug("[Page Consulting -- Linking] Retrieved company names:\n%s", company_names)
linking_entries: list[LinkingConsultationsEntryCompany] = []
for entry in company_names:
linking_entries.append(
LinkingConsultationsEntryCompany(
un_id=entry["un_id"],
display_name=entry["ma_unternehmensname"],
)
)
return linking_entries
def page_consulting_linking_persons() -> list[LinkingConsultationsEntryPerson]:
with db.ENGINE.connect() as conn:
stmt = (
sa.select(
db.t_grunderfassung_personen.c.pers_id,
db.t_zuordnung_personen_unternehmen.c.un_id,
db.t_grunderfassung_personen.c.Stammdaten__name,
db.t_grunderfassung_personen.c.Stammdaten__vorname,
db.t_grunderfassung_personen.c.Stammdaten__ort,
)
.outerjoin(
db.t_zuordnung_personen_unternehmen,
db.t_grunderfassung_personen.c.pers_id
== db.t_zuordnung_personen_unternehmen.c.pers_id,
)
.order_by(db.t_grunderfassung_personen.c.Stammdaten__name)
)
ret = conn.execute(stmt)
persons_map: dict[str, LinkingConsultationsEntryPerson] = {}
for row in ret:
p_id = row.pers_id
if p_id not in persons_map:
first_name = row.Stammdaten__vorname
last_name = row.Stammdaten__name
location_city = row.Stammdaten__ort
display_name = f"{first_name} {last_name}"
if location_city:
display_name += f" (Ort: {location_city})"
persons_map[p_id] = LinkingConsultationsEntryPerson(
pers_id=p_id,
un_ids=set(),
display_name=display_name,
)
if row.un_id is not None:
persons_map[p_id].un_ids.add(row.un_id)
return list(persons_map.values())
# TODO wrap result
def page_consulting_eastablish_link(
vorgang_id: ConsId,
un_id: RecId | None,
pers_id: RecId | None,
) -> None:
stmt = (
sa.update(db.t_beratung_vorgang)
.where(db.t_beratung_vorgang.c.vorgang_id == vorgang_id)
.values(un_id=un_id, pers_id=pers_id)
)
with db.ENGINE.begin() as conn:
conn.execute(stmt)
# // company profile interaction
def companyprofile_page_get_consultations(
un_id: RecId,
) -> CompanyProfileConsultations:
logger.debug("[Call backend] _companyprofile_page_get_consultations")
stmt = sa.select(
db.t_beratung_vorgang.c.vorgang_id,
db.t_beratung_vorgang.c.aktualisiert,
db.t_beratung_vorgang.c.titel,
db.t_beratung_vorgang.c.beratungs_typ,
).where(
db.t_beratung_vorgang.c.un_id == un_id, db.t_beratung_vorgang.c.geloescht.is_(None)
)
with db.ENGINE.connect() as conn:
res = conn.execute(stmt)
front_page_companies: list[FrontpageCompany] = []
cons_entries_pauschal: list[CompanyProfileConsultationEntry] = []
cons_entries_individual: list[CompanyProfileConsultationEntry] = []
for entry in res:
erfassung_id = entry[0]
ma_id = entry[1]
datetime_akt = cast(datetime.datetime, entry[2])
datetime_akt = datetime_akt.astimezone()
for entry in res.mappings():
cons_id = entry["vorgang_id"]
assert cons_id, "no VorgangID defined"
comp_info = comp_search_get_info(ma_id)
name = comp_info["ma_unternehmensname"]
datetime_updated = cast(datetime.datetime, entry["aktualisiert"])
datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST)
base_title = entry["titel"]
cons_type = ConsultingType(entry["beratungs_typ"])
front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt))
con_entry = CompanyProfileConsultationEntry(
cons_id=cons_id,
title=base_title,
date_updated=datetime_updated,
cons_type=cons_type,
)
return front_page_companies
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"),
)
)
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 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import zoneinfo
from pathlib import Path
from typing import Final
@@ -18,9 +19,21 @@ assert BASE_PATH
class Config:
DEVELOPMENT_STATE: bool = bool(os.getenv("DOPT_DEVELOPMENT", None))
DB_PATH_CRM: Path = BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db")
DB_PATH_MAIN: Path = BASE_PATH / os.getenv(
"DOPT_DB_MAIN", "data/db/wce_grunderfassung.db"
)
PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log")
DB_PATH_KONTAKTLISTE: Path = (
BASE_PATH / os.getenv("DOPT_DB_CRM", "data/db/wce_crm.db")
).resolve()
DB_PATH_MAIN: Path = (
BASE_PATH / os.getenv("DOPT_DB_MAIN", "data/db/wce_grunderfassung.db")
).resolve()
PATH_LOGGING: Path = (BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/logs")).resolve()
PATH_FILE_STORAGE: Path = (
BASE_PATH / os.getenv("DOPT_PATH_FILE_STORAGE", "data/files")
).resolve()
LOG_FILENAME: str = "dopt.log"
ALEMBIC_PATH: Path = (
BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic")
).resolve()
TIMEZONE_CEST: Final[zoneinfo.ZoneInfo] = zoneinfo.ZoneInfo("Europe/Berlin")
COLUMN_SEP: Final[str] = "__"
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Final
CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset(
[
"grunderfassung_unternehmen_suche",
]
)
+762
View File
@@ -0,0 +1,762 @@
from __future__ import annotations
import copy
import dataclasses as dc
import datetime
import json
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar
from dopt_basics.datastructures import DualDict
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
EmailStr,
Field,
field_validator,
model_validator,
)
from pydantic_core import ErrorDetails
from wce_crm.constants import COLUMN_SEP
from wce_crm.form_defs import INITREC_COMP, INITREC_METADATEN, INITREC_PERSON, FormField
from wce_crm.types import ConsultingType, EntityIds, EntityType, RecordingType
if TYPE_CHECKING:
from wce_crm.gui import InitRecForm, Page_Consulting_ConsultingSession # noqa: F401
from wce_crm.gui_components.initrec_file_selection import FileItemWidget
from wce_crm.types import ConsId, EntityType, RecId, UserId
ValidAge = Annotated[int, Field(ge=0, le=99)]
M = TypeVar("M", bound="Module")
S = TypeVar("S", bound="PageState")
# // validation translation
GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = {
"missing": "Dieses Feld ist ein Pflichtfeld.",
"int_parsing": "Bitte eine gültige Ganzzahl eingeben.",
"float_parsing": "Bitte eine gültige Dezimalzahl eingeben.",
"string_too_short": "Der Text ist zu kurz. Er muss mindestens {min_length} Zeichen lang sein.",
"string_too_long": "Der Text ist zu lang. Er darf maximal {max_length} Zeichen lang sein.",
"greater_than": "Der Wert muss größer als {gt} sein.",
}
def translate_pydantic_errors(
errors: list[ErrorDetails],
) -> list[ErrorDetails]:
translated_errors: list[ErrorDetails] = []
for error in errors:
err_type = error.get("type", None)
if err_type is None or err_type not in GERMAN_ERROR_MESSAGES:
error["msg"] = "[fehlende Übersetzung, nur Englisch]: " + error["msg"]
translated_errors.append(error)
continue
msg_template = GERMAN_ERROR_MESSAGES[err_type]
ctx = error.get("ctx", {})
try:
error["msg"] = msg_template.format(**ctx)
except KeyError:
error["msg"] = msg_template
translated_errors.append(error)
return translated_errors
def _parse_json(value: Any) -> str:
if isinstance(value, datetime.date):
return value.isoformat()
elif isinstance(value, datetime.datetime):
return value.isoformat()
else:
raise TypeError
# // interfaces
class WrapperModule(Protocol):
def _sync_state_to_GUI(self, *args, **kwargs) -> None:
"""build states for all child modules and use their `load_state` method
to initialise them with the new data
"""
...
def _sync_GUI_to_state(self, *args, **kwargs) -> None:
"""call `get_state` method on all child modules and assign respective
properties to state of parent module
"""
...
def load_state(self, new_state: Any) -> None: ...
def get_state(self) -> Any:
"""should always be a simple call to `_sync_GUI_to_state` and then returning
the module's state
Returns
-------
Any
state of the respective module
"""
...
def lock(self) -> None: ...
def unlock(self) -> None: ...
class Module(WrapperModule, Protocol):
def validate(self) -> list[str]:
"""should be a list of field names which could not be validated successfully,
propagates through: parent components must call this on all child modules and extend
their own error list with the return value
Returns
-------
list[str]
list of error fields (form field label)
"""
...
def save_data(self) -> None: ...
class InitRecFormToDb(Protocol):
def __call__(
self,
auto_form_data: InitRec,
) -> InitRec: ...
class InitRecFormFromDb(Protocol):
def __call__(
self,
ids: EntityIds,
) -> InitRec: ...
# // GUI states and types
def set_page_state(
target: S,
source: S,
ignore_attr: set[str] | None = None,
deep: bool = False,
) -> None:
"""
transfer all field values generically from 'source' to 'target'
"""
if not (dc.is_dataclass(target) and dc.is_dataclass(source)):
raise TypeError("Both source and target must be dataclasses")
if type(target) is not type(source):
raise TypeError("Both source and target must have the same type")
if ignore_attr is None:
ignore_attr = set()
ignore_attr.add("child_modules")
for field in dc.fields(source):
if field.name in ignore_attr:
continue
new_value = getattr(source, field.name)
if deep:
new_value = copy.deepcopy(new_value)
setattr(target, field.name, new_value)
@dc.dataclass(slots=True)
class Session:
user_id: UserId
user_name: str
@dc.dataclass(slots=True)
class PageState(Generic[M]):
child_modules: list[M] = dc.field(default_factory=list)
@dc.dataclass(slots=True, kw_only=True)
class Page_MainPage_State(PageState[Module]):
session: Session
row_count: int = 0
@dc.dataclass(slots=True, kw_only=True)
class Page_NewEntry_State(PageState[Module]):
session: Session
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_Form_Data:
metadata: dict[str, Any]
person: dict[str, Any]
company: dict[str, Any]
def _create_empty_initrec_form_data() -> Page_InitRec_Form_Data:
return Page_InitRec_Form_Data(
metadata={},
person={},
company={},
)
# !! there must be exactly one InitRecForm for each recording type
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_State(PageState["InitRecForm"]):
session: Session
ids: EntityIds = dc.field(default_factory=EntityIds)
recording_type: RecordingType
# un_id: RecId | None = None
locked: bool
geloescht: bool = False
form_data: Page_InitRec_Form_Data = dc.field(
default_factory=_create_empty_initrec_form_data
)
initrec_form_states: dict[EntityType, Page_InitRec_Form_State] = dc.field(
init=False, default_factory=dict
)
docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list)
def __post_init__(self) -> None:
relevant_entities = frozenset(
(
EntityType.METADATA,
EntityType.COMPANY,
EntityType.PERSON,
)
)
for ent_type in relevant_entities:
initrec_form_state = Page_InitRec_Form_State(
session=self.session,
cfg=INITREC_CONFIGS[ent_type],
ids=self.ids,
ent_type=ent_type,
locked=self.locked,
geloescht=self.geloescht,
)
self.initrec_form_states[ent_type] = initrec_form_state
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_Form_Config:
pydantic_model: type[FlatBaseModel]
form_fields: Sequence[FormField]
ignored_keys: Iterable[str] = tuple()
add_buttons: bool = False
id_mapping: dict[str, str]
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_Form_State(PageState[Module]):
session: Session
cfg: Page_InitRec_Form_Config
ids: EntityIds
ent_type: EntityType
# rec_id: RecId | None = None
form_data: dict[str, Any] = dc.field(default_factory=dict)
locked: bool
geloescht: bool = False
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_DocumentSection_State(PageState["FileItemWidget"]):
session: Session
doc_count: int = 0
docs: list[Page_InitRec_DocumentSection_Doc_State] = dc.field(default_factory=list)
locked: bool
geloescht: bool = False
@dc.dataclass(slots=True, kw_only=True)
class Page_InitRec_DocumentSection_Doc_State(PageState[Module]):
session: Session
id: int | None
# pers_id: int | None
datum_hinzugefuegt: datetime.datetime = dc.field(
default_factory=lambda: datetime.datetime.now(datetime.UTC)
)
speicher_pfad: Path
dateiname_original: str = ""
dateigroesse_bytes: int | None = None
mime_type: str = ""
anmerkung: str = ""
locked: bool
geloescht: bool = False
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_State(PageState[Module]):
session: Session
vorgang_id: ConsId | None
un_id: RecId | None = None
pers_id: RecId | None = None
titel: str = "TEST-TITEL" # add title later to GUI or build it in the background
beratungs_typ: ConsultingType
cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field(
default_factory=list
)
geloescht: bool = False
locked: bool
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_Linking_State(PageState[Module]):
session: Session
un_id: RecId | None = None
pers_id: RecId | None = None
beratungs_typ: ConsultingType
filter_un_ids: set[RecId] | None = None
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_ConsultingSession_State(PageState[Module]):
session: Session
nutzer_id: UserId # must be set with the session (always known)
nutzer_name: str # must be set with the session (always known)
beratung_id: int | None # known and not known entries
zeitstempel: datetime.datetime | None = None
ansprechpartner: str | None = None
kommunikationsweg: str | None = None
thema_crm_matrix: str | None = (
None # TODO should be linked to something, target unclear (need specification by customer)
)
anmerkungen: str | None = None
rueckmeldung: str | None = None
geloescht: bool = False
locked: bool
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]):
session: Session
row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list)
locked: bool
@dc.dataclass(slots=True, kw_only=True)
class Page_CompanyProfile_State(PageState[Module]):
session: Session
un_id: RecId | None = None
# // Pydantic models
class FlatBaseModel(BaseModel):
"""
Optimised Pydantic base class, which parses JSON strings and column
separators recursively and correctly
"""
@classmethod
def _recursive_parse_json(
cls,
data: Any,
) -> Any:
"""look for JSON list strings and parse them"""
if isinstance(data, str) and data.startswith("[") and data.endswith("]"):
try:
parsed = json.loads(data)
# Falls die Liste selbst wieder konvertiert werden muss (z.B. Sub-Dicts)
return cls._recursive_parse_json(parsed)
except json.JSONDecodeError:
return data
elif isinstance(data, dict):
return {k: cls._recursive_parse_json(v) for k, v in data.items()}
elif isinstance(data, list):
return [cls._recursive_parse_json(item) for item in data]
return data
@classmethod
def _recursive_unflatten(
cls,
data: Any,
) -> Any:
"""building nested structure using column spearator sequence"""
if isinstance(data, dict):
unflattened_level = {}
for key, value in data.items():
if COLUMN_SEP in key:
parts = key.split(COLUMN_SEP)
aktuell = unflattened_level
for part in parts[:-1]:
if part not in aktuell or not isinstance(aktuell[part], dict):
aktuell[part] = {}
aktuell = aktuell[part]
aktuell[parts[-1]] = value
else:
unflattened_level[key] = value
return {k: cls._recursive_unflatten(v) for k, v in unflattened_level.items()}
elif isinstance(data, list):
return [cls._recursive_unflatten(item) for item in data]
return data
@model_validator(mode="before")
@classmethod
def _unflatten_input(
cls,
data: Any,
) -> Any: # type: ignore
"""entry control: prepare flat DB/GUI data for Pydantic"""
if not isinstance(data, dict):
return data
# setp 1: convert all JSON-Strings to lists
json_parsed_data = cls._recursive_parse_json(data)
# step 2: build nested structure based on defined separator sequence
final_nested_data = cls._recursive_unflatten(json_parsed_data)
return final_nested_data
def to_db(self, *args, **kwargs) -> dict[str, Any]:
"""output for DB: flat, lists as JSON-Strings"""
nested = super().model_dump(*args, **kwargs)
return self.__flatten_dict(nested, serialize_lists=True)
def to_gui(self, *args, **kwargs) -> dict[str, Any]:
"""output for GUI: flat, but lists remain Python lists"""
nested = super().model_dump(*args, **kwargs)
return self.__flatten_dict(nested, serialize_lists=False)
@classmethod
def __flatten_dict(
cls,
nested_dict: dict,
parent_key: str = "",
serialize_lists: bool = True,
) -> dict[str, Any]:
"""recursive function to flatten the structure (for outputs)"""
items = []
for k, v in nested_dict.items():
new_key = f"{parent_key}{COLUMN_SEP}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(cls.__flatten_dict(v, new_key, serialize_lists).items())
elif isinstance(v, list):
processed_list = []
for item in v:
if isinstance(item, dict):
processed_list.append(
cls.__flatten_dict(item, serialize_lists=serialize_lists)
)
else:
processed_list.append(item)
if serialize_lists:
items.append((new_key, json.dumps(processed_list, default=_parse_json)))
else:
items.append((new_key, processed_list))
else:
items.append((new_key, v))
return dict(items)
# ** InitRec
class Grunderfassung_Dokument(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
id: int | None
pers_id: int | None
speicher_pfad: Path
dateiname_original: str
datum_hinzugefuegt: AwareDatetime
dateigroesse_bytes: int | None = None
mime_type: str
anmerkung: str
# default in SQLAlchemy with lambda and timezone-aware datetime
erstellt: AwareDatetime | None = Field(default=None, exclude=True)
aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above
geloescht: AwareDatetime | None = None
class InitRec(BaseModel):
ids: EntityIds
recording_type: RecordingType
geloescht: AwareDatetime | None = None
form_data: Page_InitRec_Form_Data
docs: list[Grunderfassung_Dokument]
# mapping between initrec form field keys and database properties
FIELD_DB_MAPPING_GRUNDERERFASSUNG_PERSONEN: Final[DualDict[str, str]] = DualDict(
Metadaten_erstellung="erstellt",
Metadaten_aktualisierung="aktualisiert",
)
FIELD_DB_MAPPING_GRUNDERERFASSUNG_UNTERNEHMEN: Final[DualDict[str, str]] = DualDict(
Partnersuche__un_suche="un_id",
Partnersuche__person_suche="an_id",
)
FIELD_DB_SPLIT_METADATEN: Final[frozenset[str]] = frozenset(
(
"Metadaten_erstellung",
"Metadaten_aktualisierung",
"Metadaten_nutzer",
"Metadaten_wiedereintrittsdatum",
"Grunderfassung_fallnummer",
"Grunderfassung_notiz",
)
)
class Grunderfassung_Metadaten(FlatBaseModel):
# displayed and handled separately, on database level included in 'Person'
Metadaten_erstellung: AwareDatetime | None = None
Metadaten_aktualisierung: AwareDatetime | None = None # see above
Metadaten_nutzer: str | None
Metadaten_wiedereintrittsdatum: datetime.date | None = None
Grunderfassung_fallnummer: str
Grunderfassung_notiz: str | None
class Grunderfassung_Unternehmen(FlatBaseModel):
# default in SQLAlchemy with lambda and timezone-aware datetime
# Metadaten_erstellung: AwareDatetime | None = None
# Metadaten_aktualisierung: AwareDatetime | None = None # see above
# Metadaten_nutzer: str | None
# Grunderfassung_fallnummer: str
# Grunderfassung_notiz: str | None
Partnersuche: Grunderfassung_PartnerSuche
class Grunderfassung_Personen(FlatBaseModel):
# default in SQLAlchemy with lambda and timezone-aware datetime
# Metadaten_erstellung: AwareDatetime | None = None
# Metadaten_aktualisierung: AwareDatetime | None = None # see above
# Metadaten_nutzer: str | None
Projektrelevanz: Grunderfassung_Projektrelevanz
Kontaktperson: Grunderfassung_Kontaktperson
Stammdaten: Grunderfassung_Stammdaten
WeitereInfos: Grunderfassung_WeitereInfos
Schulbildung: list[Grunderfassung_Schulbildung]
HoehereBildung: list[Grunderfassung_HoehereBildung]
Arbeitserfahrung: list[Grunderfassung_Arbeitserfahrung]
Sprachkenntnisse: list[Grunderfassung_Sprachen]
class Grunderfassung_PartnerSuche(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
un_suche: int | None
person_suche: int | None
kontaktweg: str | None
class Grunderfassung_Projektrelevanz(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
relevanz: str
foerderperiode: str | None = None
class Grunderfassung_Kontaktperson(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
KP_name_partner: str | None
KP_titel: str | None
KP_anrede_anschrift: str | None
KP_name: str | None
KP_vorname: str | None
KP_festnetznummer: str | None
KP_mobilfunknummer: str | None
KP_email: EmailStr | None
KP_funktion_beziehung: str | None
KP_adresse: str | None
class Grunderfassung_Stammdaten(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
kanal_aufmerksamkeit: str | None
titel: str | None
anrede_anschrift: str
name: str
vorname: str | None
geburtsdatum: datetime.date | None
herkunftsland: str
staatsangehoerigkeit: str | None
rueckkehrer: bool | None
aufenthaltsort: str | None
strasse: str | None
hausnummer: str | None
PLZ: str | None
ort: str | None
bundesland: str | None
land: str | None
festnetznummer: str | None
mobilfunknummer: str | None
email: EmailStr | None
familienstand: str | None
anzahl_kinder: Grunderfassung_Stammdaten_AnzahlKinder
@field_validator("rueckkehrer", mode="before")
@classmethod
def str_to_bool(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip().lower()
if value == "ja":
return True
elif value == "nein":
return False
raise ValueError("Wert muss 'ja', 'nein', True oder False sein.")
return value
class Grunderfassung_Stammdaten_AnzahlKinder(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
anzahl: int | None
alter: list[ValidAge | None] | None = None
class Grunderfassung_WeitereInfos(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
WI_deutsch_sprache: str | None
WI_aufenthaltstitel: str | None
WI_gueltigkeit_aufenthaltstitel: datetime.date | None
WI_arbeitsstatus: str | None
WI_meldung_institution: str | None
class Grunderfassung_Schulbildung(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
SB_abschluss: str | None
SB_abschlussgrad: str | None
SB_schule: str | None
SB_ort: str | None
SB_land: str | None
SB_abschlussjahr: str | None
SB_bemerkungsfeld: str | None
class Grunderfassung_HoehereBildung(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
HB_anerkennung: str | None
HB_abschlussgrad: str | None
HB_abschlussgrad_dokument: str | None
HB_organisation: str | None
HB_beruf: str | None
HB_land: str | None
HB_ort: str | None
HB_abschlussjahr: str | None
HB_bemerkungsfeld: str | None
class Grunderfassung_Arbeitserfahrung(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
AE_branche: str | None
AE_bezeichnung: str | None
AE_funktion: str | None
AE_unternehmen: str | None
AE_land: str | None
AE_zeitspanne: str | None
AE_beschaeftigungsart: str | None
AE_bemerkungsfeld: str | None
class Grunderfassung_Sprachen(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
SP_sprache: str | None
SP_niveau: str | None
SP_nachweis: str | None
SP_art_nachweis: str | None = None
SP_datum_nachweis: datetime.date | None = None
# TODO placeholer docuemnt
# ** Consulting
class Beratungsgespraech_Vorgang(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
vorgang_id: int | None
un_id: int | None
pers_id: int | None
titel: str
beratungs_typ: ConsultingType
# default in SQLAlchemy with lambda and timezone-aware datetime
erstellt: AwareDatetime | None = Field(default=None, exclude=True)
aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above
geloescht: AwareDatetime | None = None
beratungen: list[Beratungsgespraech_Einzelgespraech]
class Beratungsgespraech_Einzelgespraech(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
vorgang_id: int | None
beratung_id: int | None
nutzer_id: int
nutzer_name: str
zeitstempel: AwareDatetime
ansprechpartner: str
kommunikationsweg: str
thema_crm_matrix: str | None
anmerkungen: str | None
rueckmeldung: str | None
# default in SQLAlchemy with lambda and timezone-aware datetime
erstellt: AwareDatetime | None = Field(default=None, exclude=True)
aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above
geloescht: AwareDatetime | None = None
INITREC_CONFIGS: Final[dict[EntityType, Page_InitRec_Form_Config]] = {
EntityType.METADATA: Page_InitRec_Form_Config(
pydantic_model=Grunderfassung_Metadaten,
ignored_keys=(
"Metadaten_erstellung",
"Metadaten_aktualisierung",
),
form_fields=INITREC_METADATEN,
add_buttons=False,
id_mapping={},
),
EntityType.COMPANY: Page_InitRec_Form_Config(
pydantic_model=Grunderfassung_Unternehmen,
ignored_keys=tuple(),
form_fields=INITREC_COMP,
add_buttons=False,
id_mapping={
"Partnersuche__un_suche": "un_id",
"Partnersuche__person_suche": "an_id",
},
),
EntityType.PERSON: Page_InitRec_Form_Config(
pydantic_model=Grunderfassung_Personen,
ignored_keys=tuple(),
# ignored_keys=(
# "Metadaten_erstellung",
# "Metadaten_aktualisierung",
# ),
form_fields=INITREC_PERSON,
add_buttons=False,
id_mapping={},
),
}
+415 -204
View File
@@ -6,13 +6,14 @@ import re
from pathlib import Path
import polars as pl
import sqlalchemy as sql
from sqlalchemy import Column, String, Table, TypeDecorator
import sqlalchemy as sa
from sqlalchemy import Column, Index, String, Table, TypeDecorator, event, sql
from wce_crm import constants
from wce_crm import types as t
# // declarations
class SafeDateTime(TypeDecorator):
"""Cleans non-standard ISO strings before parsing."""
@@ -36,7 +37,7 @@ class SafeDateTime(TypeDecorator):
class UTCDateTime(TypeDecorator):
"""Safely coerces naive datetimes from SQLite into timezone-aware UTC."""
impl = sql.DateTime
impl = sa.DateTime
cache_ok = True
def process_bind_param(self, value, dialect):
@@ -44,128 +45,70 @@ class UTCDateTime(TypeDecorator):
if value is not None:
# Ensure it's converted to UTC before saving
if value.tzinfo is None:
value = value.replace(tzinfo=datetime.timezone.utc)
value = value.replace(tzinfo=datetime.UTC)
else:
value = value.astimezone(datetime.timezone.utc)
value = value.astimezone(datetime.UTC)
return value
def process_result_value(self, value, dialect):
"""Runs when fetching from the database."""
if value is not None and value.tzinfo is None:
# Explicitly tell Python this data *is* UTC
value = value.replace(tzinfo=datetime.timezone.utc)
value = value.replace(tzinfo=datetime.UTC)
return value
md_crm = sql.MetaData()
md_main = sql.MetaData()
ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}")
MD_CRM = sa.MetaData()
MD_MAIN = sa.MetaData()
URL_MAIN = sa.engine.URL.create(
"sqlite+pysqlite",
database=str(constants.Config.DB_PATH_MAIN),
)
ENGINE = sa.create_engine(
URL_MAIN,
connect_args={"uri": True, "timeout": 0.5},
)
# ---------- OLD "Kontaktliste" ----------
# md_kontaktliste = sql.MetaData()
# ext_kl_unternehmen: sql.Table = Table(
# "Unternehmen",
# md_kontaktliste,
# Column("u_id", sql.Integer, nullable=False, unique=True),
# Column("u_zeitstempel_eintrag", sql.DateTime, nullable=False),
# Column("u_rechtsform", sql.Text, nullable=False),
# Column("u_firmenname", sql.Text, nullable=False),
# Column("u_strasse", sql.Text, nullable=False),
# Column("u_hausnummer", sql.Text, nullable=False),
# Column("u_adresszusatz", sql.Text, nullable=True),
# Column("u_plz", sql.Text, nullable=False),
# Column("u_ort", sql.Text, nullable=False),
# Column("u_postfach", sql.Text, nullable=True),
# Column("u_website", sql.Text, nullable=True),
# Column("u_anrede", sql.Text, nullable=False),
# Column("u_titel", sql.Text, nullable=True),
# Column("u_vorname", sql.Text, nullable=False),
# Column("u_nachname", sql.Text, nullable=False),
# Column("u_funktion", sql.Text, nullable=False),
# Column("u_mail", sql.Text, nullable=False),
# Column("u_telefon", sql.Text, nullable=False),
# Column("u_plz_postfach", sql.Text, nullable=True),
# Column("u_einwilligung_inhaber", sql.Boolean, nullable=True),
# Column("u_einwilligung_ansprechpartner", sql.Boolean, nullable=True),
# Column("u_aktiv", sql.Boolean, nullable=False, default=1),
# )
# ext_kl_unternehmen_schema: t.PolarsSchema = {
# "u_id": pl.UInt64,
# "u_zeitstempel_eintrag": pl.Datetime,
# "u_rechtsform": pl.String,
# "u_firmenname": pl.String,
# "u_strasse": pl.String,
# "u_hausnummer": pl.String,
# "u_adresszusatz": pl.String,
# "u_plz": pl.String,
# "u_ort": pl.String,
# "u_postfach": pl.String,
# "u_website": pl.String,
# "u_anrede": pl.String,
# "u_titel": pl.String,
# "u_vorname": pl.String,
# "u_nachname": pl.String,
# "u_funktion": pl.String,
# "u_mail": pl.String,
# "u_telefon": pl.String,
# "u_plz_postfach": pl.String,
# "u_einwilligung_inhaber": pl.Boolean,
# "u_einwilligung_ansprechpartner": pl.Boolean,
# "u_aktiv": pl.Boolean,
# }
KONTAKTLISTE_URI = constants.Config.DB_PATH_KONTAKTLISTE.as_uri() + "?mode=ro"
# def get_ext_kontaktliste(
# db_path: Path | None,
# ) -> pl.DataFrame:
# if db_path is None:
# ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None)
# if ENV_PTH is None:
# raise ValueError("No database path provided or found as ENV var.")
# db_path = Path(ENV_PTH)
# if not db_path.exists():
# raise FileNotFoundError(f"Database not found under >{db_path}<")
# engine = sql.create_engine(f"sqlite:///{db_path}")
# stmt = sql.select(ext_kl_unternehmen)
# return pl.read_database(stmt, engine, schema_overrides=ext_kl_unternehmen_schema)
@event.listens_for(ENGINE, "connect")
def _db_conn_event(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
# 'kontaktliste' is the alias for the second DB
cursor.execute("ATTACH DATABASE ? AS kontaktliste", (KONTAKTLISTE_URI,))
# enable foreign keys
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# df_kontaktliste = get_ext_kontaktliste(None)
# ----------------------------------------------------
ext_crm_master: sql.Table = Table(
ext_crm_master: Table = Table(
"Master",
md_crm,
Column("ma_id", sql.Integer, nullable=False, unique=True),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")),
Column("ma_unternehmensname", sql.Text, nullable=True),
Column("ma_branche", sql.Text, nullable=True),
Column("ma_strasse", sql.Text, nullable=True),
Column("ma_hausnummer", sql.Text, nullable=True),
Column("ma_plz", sql.Text, nullable=True),
Column("ma_ort", sql.Text, nullable=True),
Column("ma_plz_postfach", sql.Text, nullable=True),
Column("ma_postfach", sql.Text, nullable=True),
Column("ma_website", sql.Text, nullable=True),
Column("ma_mail", sql.Text, nullable=True),
Column("ma_telefonnummer", sql.Text, nullable=True),
Column("ma_faxnummer", sql.Text, nullable=True),
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", sql.Text, nullable=True),
Column("ma_sollprozess", sql.Text, nullable=True),
Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True),
Column("ma_quelle_information", sql.Text, nullable=True),
Column("ma_bemerkung", sql.Text, nullable=True),
Column("ma_kontakt", sql.Boolean, nullable=True),
Column("ma_schlagworte", sql.Text, nullable=True),
Column("ma_archiviert", sql.Boolean, nullable=True, default=False),
Column("ma_aktualisierung_nutzer", sa.Text, nullable=True),
Column("ma_sollprozess", sa.Text, nullable=True),
Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True),
Column("ma_quelle_information", sa.Text, nullable=True),
Column("ma_bemerkung", sa.Text, nullable=True),
Column("ma_kontakt", sa.Boolean, nullable=True),
Column("ma_schlagworte", sa.Text, nullable=True),
Column("ma_archiviert", sa.Boolean, nullable=True, default=False),
)
@@ -209,26 +152,26 @@ def get_ext_crm_master(
if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_master)
engine = sa.create_engine(f"sqlite:///{db_path}")
stmt = sa.select(ext_crm_master)
return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema)
DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_CRM)
DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_KONTAKTLISTE)
ext_crm_nutzer: sql.Table = Table(
ext_crm_nutzer: Table = Table(
"Nutzer",
md_crm,
Column("wce_id", sql.Integer, nullable=False, unique=True),
Column("wce_name", sql.Text, nullable=True),
Column("wce_vorname", sql.Text, nullable=True),
Column("wce_kuerzel", sql.Text, nullable=True),
Column("wce_passwort", sql.Text, nullable=True),
Column("wce_angelegt_am", sql.DateTime, nullable=True),
Column("wce_rolle", sql.Text, nullable=True),
Column("wce_angelegt_von", sql.Text, nullable=True),
Column("wce_aktiv", sql.Boolean, nullable=True),
Column("wce_letzter_login", sql.DateTime, nullable=True),
MD_CRM,
Column("wce_id", sa.Integer, nullable=False, unique=True),
Column("wce_name", sa.Text, nullable=True),
Column("wce_vorname", sa.Text, nullable=True),
Column("wce_kuerzel", sa.Text, nullable=True),
Column("wce_passwort", sa.Text, nullable=True),
Column("wce_angelegt_am", sa.DateTime, nullable=True),
Column("wce_rolle", sa.Text, nullable=True),
Column("wce_angelegt_von", sa.Text, nullable=True),
Column("wce_aktiv", sa.Boolean, nullable=True),
Column("wce_letzter_login", sa.DateTime, nullable=True),
)
ext_crm_nutzer_schema: t.PolarsSchema = {
@@ -244,31 +187,31 @@ ext_crm_nutzer_schema: t.PolarsSchema = {
"wce_letzter_login": pl.Datetime,
}
ext_crm_contact_person: sql.Table = Table(
ext_crm_contact_person: Table = Table(
"Ansprechpartner",
md_crm,
Column("an_id", sql.Integer, nullable=False, unique=True),
Column("ma_id", sql.ForeignKey("Master.ma_id")),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")),
Column("st_id", sql.Integer, nullable=False),
Column("an_sachgebiet", sql.Text, nullable=True),
Column("an_anrede", sql.Text, nullable=True),
Column("an_titel", sql.Text, nullable=True),
Column("an_nachname", sql.Text, nullable=True),
Column("an_vorname", sql.Text, nullable=True),
Column("an_position", sql.Text, nullable=True),
Column("an_mail", sql.Text, nullable=True),
Column("an_festnetz", sql.Text, nullable=True),
Column("an_mobil", sql.Text, nullable=True),
Column("an_faxnummer", sql.Text, nullable=True),
Column("an_hauptansprechpartner", sql.Text, nullable=True),
Column("an_anrede_anschrift", sql.Text, nullable=True),
Column("an_bemerkung", sql.Text, nullable=True),
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", sql.Text, nullable=True),
Column("an_aktualisierung_nutzer", sa.Text, nullable=True),
Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True),
Column("an_ersteintrag_datum", SafeDateTime, nullable=True),
Column("an_archiviert", sql.Boolean, nullable=True, default=0),
Column("an_archiviert", sa.Boolean, nullable=True, default=0),
)
@@ -310,8 +253,8 @@ def get_ext_crm_contact_person(
if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_contact_person)
engine = sa.create_engine(f"sqlite:///{db_path}")
stmt = sa.select(ext_crm_contact_person)
df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema)
df = df.with_columns(
pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ")
@@ -321,80 +264,348 @@ def get_ext_crm_contact_person(
# df_contact_person = get_ext_crm_contact_person(None)
DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM)
DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_KONTAKTLISTE)
grunderfassung_unternehmen: sql.Table = Table(
"grunderfassung_unternehmen",
md_main,
t_ext_crm_master_attach: Table = Table(
"Master",
MD_CRM,
Column("ma_id", sa.Integer, nullable=False, unique=True),
Column("wce_id", sa.ForeignKey("Nutzer.wce_id")),
Column("ma_unternehmensname", sa.Text, nullable=True),
Column("ma_branche", sa.Text, nullable=True),
Column("ma_strasse", sa.Text, nullable=True),
Column("ma_hausnummer", sa.Text, nullable=True),
Column("ma_plz", sa.Text, nullable=True),
Column("ma_ort", sa.Text, nullable=True),
Column("ma_plz_postfach", sa.Text, nullable=True),
Column("ma_postfach", sa.Text, nullable=True),
Column("ma_website", sa.Text, nullable=True),
Column("ma_mail", sa.Text, nullable=True),
Column("ma_telefonnummer", sa.Text, nullable=True),
Column("ma_faxnummer", sa.Text, nullable=True),
Column("ma_ersteintrag_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_nutzer", sa.Text, nullable=True),
Column("ma_sollprozess", sa.Text, nullable=True),
Column("ma_auslaendische_mitarbeiter", sa.Text, nullable=True),
Column("ma_quelle_information", sa.Text, nullable=True),
Column("ma_bemerkung", sa.Text, nullable=True),
Column("ma_kontakt", sa.Boolean, nullable=True),
Column("ma_schlagworte", sa.Text, nullable=True),
Column("ma_archiviert", sa.Boolean, nullable=True, default=False),
schema="kontaktliste",
)
t_ext_crm_contact_person_attach: Table = Table(
"Ansprechpartner",
MD_CRM,
Column("an_id", sa.Integer, nullable=False, unique=True),
Column("ma_id", sa.ForeignKey("Master.ma_id")),
Column("wce_id", sa.ForeignKey("Nutzer.wce_id")),
Column("st_id", sa.Integer, nullable=False),
Column("an_sachgebiet", sa.Text, nullable=True),
Column("an_anrede", sa.Text, nullable=True),
Column("an_titel", sa.Text, nullable=True),
Column("an_nachname", sa.Text, nullable=True),
Column("an_vorname", sa.Text, nullable=True),
Column("an_position", sa.Text, nullable=True),
Column("an_mail", sa.Text, nullable=True),
Column("an_festnetz", sa.Text, nullable=True),
Column("an_mobil", sa.Text, nullable=True),
Column("an_faxnummer", sa.Text, nullable=True),
Column("an_hauptansprechpartner", sa.Text, nullable=True),
Column("an_anrede_anschrift", sa.Text, nullable=True),
Column("an_bemerkung", sa.Text, nullable=True),
Column("an_aktualisierung_datum", SafeDateTime, nullable=True),
Column("an_aktualisierung_nutzer", sa.Text, nullable=True),
Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True),
Column("an_ersteintrag_datum", SafeDateTime, nullable=True),
Column("an_archiviert", sa.Boolean, nullable=True, default=0),
schema="kontaktliste",
)
# grunderfassung_unternehmen: Table = Table(
# "grunderfassung_unternehmen",
# MD_MAIN,
# Column(
# "un_id",
# sa.Integer,
# primary_key=True,
# autoincrement=True,
# ),
# Column(
# "geloescht",
# UTCDateTime,
# nullable=True,
# ),
# Column(
# "Metadaten_erstellung",
# UTCDateTime,
# nullable=True,
# default=lambda: datetime.datetime.now(datetime.UTC),
# ),
# Column(
# "Metadaten_aktualisierung",
# UTCDateTime,
# nullable=True,
# default=lambda: datetime.datetime.now(datetime.UTC),
# onupdate=lambda: datetime.datetime.now(datetime.UTC),
# ),
# Column("Metadaten_nutzer", sa.String(20), nullable=True),
# Column("Grunderfassung_fallnummer", sa.Text, nullable=True),
# Column("Grunderfassung_notiz", sa.Text, nullable=True),
# Column("Partnersuche__kanal_aufmerksamkeit", sa.Text, nullable=True, default=None),
# Column("Partnersuche__person_suche", sa.Integer, nullable=True, default=None),
# Column("Partnersuche__un_suche", sa.Integer, nullable=True, default=None),
# )
t_unternehmen: Table = Table(
"Unternehmen",
MD_MAIN,
Column(
"erfassung_id",
sql.Integer,
"un_id",
sa.Integer,
primary_key=True,
autoincrement=True,
),
Column(
"Metadaten_erstellung",
"erstellt",
UTCDateTime,
nullable=True,
nullable=False,
default=lambda: datetime.datetime.now(datetime.UTC),
),
Column(
"Metadaten_aktualisierung",
"geloescht",
UTCDateTime,
nullable=True,
),
)
t_grunderfassung_personen: Table = Table(
"Grunderfassung_Personen",
MD_MAIN,
Column(
"pers_id",
sa.Integer,
primary_key=True,
autoincrement=True,
),
Column(
"erstellt",
UTCDateTime,
nullable=False,
default=lambda: datetime.datetime.now(datetime.UTC),
),
Column(
"aktualisiert",
UTCDateTime,
nullable=False,
default=lambda: datetime.datetime.now(datetime.UTC),
onupdate=lambda: datetime.datetime.now(datetime.UTC),
),
Column("Metadaten_nutzer", sql.String(20), nullable=True),
Column("Arbeitserfahrung", sql.Text, nullable=True),
Column("Grunderfassung_fallnummer", sql.Text, nullable=True),
Column("Grunderfassung_notiz", sql.Text, nullable=True),
Column("HoehereBildung", sql.Text, nullable=True),
Column("Kontaktperson__KP_adresse", sql.Text, nullable=True),
Column("Kontaktperson__KP_anrede_anschrift", sql.Text, nullable=True),
Column("Kontaktperson__KP_email", sql.Text, nullable=True),
Column("Kontaktperson__KP_festnetznummer", sql.Text, nullable=True),
Column("Kontaktperson__KP_funktion_beziehung", sql.Text, nullable=True),
Column("Kontaktperson__KP_mobilfunknummer", sql.Text, nullable=True),
Column("Kontaktperson__KP_name", sql.Text, nullable=True),
Column(
"Kontaktperson__KP_name_partner", sql.Text, nullable=True
), # TODO: check if needed when set by trigger
Column("Kontaktperson__KP_titel", sql.Text, nullable=True),
Column("Kontaktperson__KP_vorname", sql.Text, nullable=True),
Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True),
Column("Partnersuche__person_suche", sql.Integer, nullable=True),
Column("Partnersuche__un_suche", sql.Integer, nullable=True),
Column("Projektrelevanz__relevanz", sql.Text, nullable=True),
Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True),
Column("Schulbildung", sql.Text, nullable=True),
Column("Sprachkenntnisse", sql.Text, nullable=True),
Column("Stammdaten__PLZ", sql.Text, nullable=True),
Column("Stammdaten__anrede_anschrift", sql.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__alter", sql.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__anzahl", sql.Integer, nullable=True),
Column("Stammdaten__aufenthaltsort", sql.Text, nullable=True),
Column("Stammdaten__bundesland", sql.Text, nullable=True),
Column("Stammdaten__land", sql.Text, nullable=True),
Column("Stammdaten__email", sql.Text, nullable=True),
Column("Stammdaten__familienstand", sql.Text, nullable=True),
Column("Stammdaten__festnetznummer", sql.Text, nullable=True),
Column("Stammdaten__geburtsdatum", sql.Date, nullable=True),
Column("Stammdaten__hausnummer", sql.Text, nullable=True),
Column("Stammdaten__herkunftsland", sql.Text, nullable=True),
Column("Stammdaten__mobilfunknummer", sql.Text, nullable=True),
Column("Stammdaten__name", sql.Text, nullable=True),
Column("Stammdaten__ort", sql.Text, nullable=True),
Column("Stammdaten__rueckkehrer", sql.Boolean, nullable=True),
Column("Stammdaten__staatsangehoerigkeit", sql.Text, nullable=True),
Column("Stammdaten__strasse", sql.Text, nullable=True),
Column("Stammdaten__titel", sql.Text, nullable=True),
Column("Stammdaten__vorname", sql.Text, nullable=True),
Column("WeitereInfos__WI_arbeitsstatus", sql.Text, nullable=True),
Column("WeitereInfos__WI_aufenthaltstitel", sql.Text, nullable=True),
Column("WeitereInfos__WI_deutsch_sprache", sql.Text, nullable=True),
Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True),
Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True),
"geloescht",
UTCDateTime,
nullable=True,
),
Column("Metadaten_nutzer", sa.String(20), nullable=True),
Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None),
Column("Arbeitserfahrung", sa.Text, nullable=True),
Column("Grunderfassung_fallnummer", sa.Text, nullable=True),
Column("Grunderfassung_notiz", sa.Text, nullable=True),
Column("HoehereBildung", sa.Text, nullable=True),
Column("Kontaktperson__KP_adresse", sa.Text, nullable=True),
Column("Kontaktperson__KP_anrede_anschrift", sa.Text, nullable=True),
Column("Kontaktperson__KP_email", sa.Text, nullable=True),
Column("Kontaktperson__KP_festnetznummer", sa.Text, nullable=True),
Column("Kontaktperson__KP_funktion_beziehung", sa.Text, nullable=True),
Column("Kontaktperson__KP_mobilfunknummer", sa.Text, nullable=True),
Column("Kontaktperson__KP_name", sa.Text, nullable=True),
Column("Kontaktperson__KP_name_partner", sa.Text, nullable=True),
Column("Kontaktperson__KP_titel", sa.Text, nullable=True),
Column("Kontaktperson__KP_vorname", sa.Text, nullable=True),
Column("Projektrelevanz__relevanz", sa.Text, nullable=True),
Column("Projektrelevanz__foerderperiode", sa.Text, nullable=True),
Column("Schulbildung", sa.Text, nullable=True),
Column("Sprachkenntnisse", sa.Text, nullable=True),
Column("Stammdaten__kanal_aufmerksamkeit", sa.Text, nullable=True),
Column("Stammdaten__PLZ", sa.Text, nullable=True),
Column("Stammdaten__anrede_anschrift", sa.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__alter", sa.Text, nullable=True),
Column("Stammdaten__anzahl_kinder__anzahl", sa.Integer, nullable=True),
Column("Stammdaten__aufenthaltsort", sa.Text, nullable=True),
Column("Stammdaten__bundesland", sa.Text, nullable=True),
Column("Stammdaten__land", sa.Text, nullable=True),
Column("Stammdaten__email", sa.Text, nullable=True),
Column("Stammdaten__familienstand", sa.Text, nullable=True),
Column("Stammdaten__festnetznummer", sa.Text, nullable=True),
Column("Stammdaten__geburtsdatum", sa.Date, nullable=True),
Column("Stammdaten__hausnummer", sa.Text, nullable=True),
Column("Stammdaten__herkunftsland", sa.Text, nullable=True),
Column("Stammdaten__mobilfunknummer", sa.Text, nullable=True),
Column("Stammdaten__name", sa.Text, nullable=True),
Column("Stammdaten__ort", sa.Text, nullable=True),
Column("Stammdaten__rueckkehrer", sa.Boolean, nullable=True),
Column("Stammdaten__staatsangehoerigkeit", sa.Text, nullable=True),
Column("Stammdaten__strasse", sa.Text, nullable=True),
Column("Stammdaten__titel", sa.Text, nullable=True),
Column("Stammdaten__vorname", sa.Text, nullable=True),
Column("WeitereInfos__WI_arbeitsstatus", sa.Text, nullable=True),
Column("WeitereInfos__WI_aufenthaltstitel", sa.Text, nullable=True),
Column("WeitereInfos__WI_deutsch_sprache", sa.Text, nullable=True),
Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.Date, nullable=True),
Column("WeitereInfos__WI_meldung_institution", sa.Text, nullable=True),
)
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,
),
)
+36
View File
@@ -0,0 +1,36 @@
from alembic import command
from alembic.config import Config
from wce_crm import constants
from wce_crm.logging import logger_base as logger
def check_and_run_migrations() -> None:
logger.info("[DB migration - Alembic] Start...")
alembic_base_folder = constants.Config.ALEMBIC_PATH
alembic_ini_file = alembic_base_folder / "alembic.ini"
alembic_working_dir = alembic_base_folder / "alembic"
assert alembic_ini_file.exists(), "Alembic INI file not found"
assert alembic_working_dir.exists(), "Alembic working dir not found"
assert alembic_working_dir.is_dir(), "Alembic working dir is not a directory"
logger.info("[DB migration - Alembic] Initialise config...")
alembic_cfg = Config(alembic_ini_file)
alembic_cfg.set_main_option("script_location", str(alembic_working_dir))
logger.info("[DB migration - Alembic] Run...")
try:
command.upgrade(alembic_cfg, "head")
logger.info("[DB migration - Alembic] Database up-to-date.")
except Exception as err:
logger.info(
"[DB migration - Alembic] An error occurred during the migration:\n%s",
err,
stack_info=True,
)
if __name__ == "__main__":
# start migration process before app start
check_and_run_migrations()
+998
View File
@@ -0,0 +1,998 @@
from __future__ import annotations
import dataclasses as dc
import enum
import uuid
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar
import babel
from wce_crm.custom_widget_registry import CUSTOM_WIDGET_NAMES
if TYPE_CHECKING:
from PySide6.QtWidgets import QWidget
T = TypeVar("T", bound="QWidget")
class FormFieldType(enum.StrEnum):
GROUP = enum.auto()
TEXT = enum.auto()
LONGTEXT = enum.auto()
DATE = enum.auto()
DATE_FLEXIBLE = enum.auto()
DATETIME = enum.auto()
DATETIME_FLEXIBLE = enum.auto()
DROPDOWN = enum.auto()
EXTENDED_DROPDOWN = enum.auto()
DYNAMIC_LIST = enum.auto()
DYNAMIC_DROPDOWN_NUMERIC = enum.auto()
DYNAMIC_DROPDOWN_OPTION = enum.auto()
TEXT_SEARCH = enum.auto()
CUSTOM = enum.auto()
TEXT_DATE = enum.auto()
TEXT_DATETIME = enum.auto()
@dc.dataclass(slots=True)
class FormField(Generic[T]):
label: str
type: FormFieldType
children: Sequence[FormField] = dc.field(default_factory=list)
parent: FormField | None = None
required: bool = False
placeholder: str = ""
fill_value: str = ""
readonly: bool = False
options: dc.InitVar[Sequence[tuple[str, Any]]] = tuple()
dropdown_options: Sequence[DropdownOption] = dc.field(default=tuple(), init=False)
key: str = ""
tooltip: str = ""
info: str = ""
custom_widget: str = ""
init_label: str = dc.field(init=False)
ignore_get_data: bool = False
trigger_value: str = ""
enable_uuid_key: bool = False
def __post_init__(
self,
options: Sequence[tuple[str, Any]],
) -> None:
if not self.key and self.enable_uuid_key:
self.key = str(uuid.uuid4())
self.label = self.label.strip()
self.init_label = self.label.replace("*", "").replace(":", "")
if not self.label.endswith(":") and self.type is not FormFieldType.GROUP:
self.label += ":"
if self.required:
self.label += "*"
if self.type is FormFieldType.CUSTOM and not self.custom_widget:
raise ValueError("Custom widget must be named using parameter >custom_widget<")
elif (
self.type is FormFieldType.CUSTOM
and self.custom_widget not in CUSTOM_WIDGET_NAMES
):
raise KeyError(
(
f"Custom widget >{self.custom_widget}< is not a known member "
"of the custom widget registry"
)
)
if self.type in (FormFieldType.DROPDOWN, FormFieldType.EXTENDED_DROPDOWN):
self.dropdown_options = tuple(DropdownOption(op[0], op[1]) for op in options)
if self.type is FormFieldType.DYNAMIC_DROPDOWN_OPTION and not self.trigger_value:
raise ValueError(
"Dynamic Dropdown Option Widget must have a defined option or decision value"
)
if self.children:
self.required = self.required or any((child.required for child in self.children))
for child in self.children:
child.parent = self
def enhanced_label(
self,
add_text: str,
) -> str:
enhanced_label = self.init_label + f" {add_text}"
if not enhanced_label.endswith(":") and self.type is not FormFieldType.GROUP:
enhanced_label += ":"
if self.required:
enhanced_label += "*"
return enhanced_label
@dc.dataclass(slots=True)
class DropdownOption:
label: str
_data: dc.InitVar[Any | None] = None
data: Any = dc.field(init=False)
def __post_init__(
self,
_data: Any | None,
) -> None:
if _data is None:
self.data = self.label
else:
self.data = _data
@dc.dataclass(slots=True)
class CountryList:
iso_to_country: dict[str, str]
for_dropdown: Sequence[tuple[str, str]]
def get_country_list_german() -> CountryList:
locale = babel.Locale("en", "GB")
countries: list[tuple[str, str]] = []
iso_to_country: dict[str, str] = {}
for iso_code, country_name in locale.territories.items():
if len(iso_code) == 2 and not iso_code.isdigit():
countries.append((country_name, iso_code))
iso_to_country[iso_code] = country_name
countries.sort(key=lambda x: x[0])
return CountryList(
iso_to_country=iso_to_country,
for_dropdown=tuple(countries),
)
def get_list_germany_states() -> CountryList:
states: list[tuple[str, str]] = []
short_code_to_name: dict[str, str] = {}
STATE_LIST: list[tuple[str, str]] = [
("Bayern", "BY"),
("Niedersachen", "NI"),
("Baden-Württemberg", "BW"),
("Berlin", "BE"),
("Brandenburg", "BB"),
("Bremen", "HB"),
("Hamburg", "HH"),
("Hessen", "HE"),
("Mecklenburg", "MV"),
("Nordrhein-Westfalen", "NW"),
("Rheinland-Pfalz", "RP"),
("Saarland", "SL"),
("Sachsen", "SN"),
("Sachsen-Anhalt", "ST"),
("Schleswig-Holstein", "SH"),
("Thüringen", "TH"),
]
STATE_LIST.sort(key=lambda x: x[1])
for country_name, code in STATE_LIST:
states.append((country_name, code))
short_code_to_name[code] = country_name
return CountryList(
iso_to_country=short_code_to_name,
for_dropdown=tuple(states),
)
COUNTRY_LIST: Final[CountryList] = get_country_list_german()
GERMAN_STATE_LIST: Final[CountryList] = get_list_germany_states()
_INITREC_COMP_SEARCH_HEAD = [
FormField(
"Suche",
FormFieldType.EXTENDED_DROPDOWN,
required=True,
key="un_suche",
placeholder="Suche...",
),
FormField(
"Name Unternehmen/Netzwerkpartner",
FormFieldType.TEXT,
required=False,
key="un_name",
readonly=True,
info="ma_unternehmensname",
),
FormField(
"Straße",
FormFieldType.TEXT,
required=False,
key="un_straße",
readonly=True,
info="ma_strasse",
),
FormField(
"Hausnummer",
FormFieldType.TEXT,
required=False,
key="un_hausnummer",
readonly=True,
info="ma_hausnummer",
),
FormField(
"PLZ",
FormFieldType.TEXT,
required=False,
key="un_PLZ",
readonly=True,
info="ma_plz",
),
FormField(
"Ort",
FormFieldType.TEXT,
required=False,
key="un_ort",
readonly=True,
info="ma_ort",
),
FormField(
"Suche Ansprechpartner",
FormFieldType.EXTENDED_DROPDOWN,
required=True,
key="person_suche",
placeholder="Suche...",
),
FormField(
"Titel",
FormFieldType.TEXT,
required=False,
key="person_titel",
readonly=True,
info="an_titel",
),
FormField(
"Anrede",
FormFieldType.TEXT,
required=False,
key="person_anrede",
readonly=True,
info="an_anrede",
),
FormField(
"Name",
FormFieldType.TEXT,
required=False,
key="person_name",
readonly=True,
info="an_nachname",
),
FormField(
"Vorname",
FormFieldType.TEXT,
required=False,
key="person_vorname",
readonly=True,
info="an_vorname",
),
FormField(
"Telefon",
FormFieldType.TEXT,
required=False,
key="person_telefon",
readonly=True,
info="an_festnetz",
),
FormField(
"Mobil",
FormFieldType.TEXT,
required=False,
key="person_mobilfunk",
readonly=True,
info="an_mobil",
),
FormField(
"E-Mail",
FormFieldType.TEXT,
required=False,
key="person_email",
readonly=True,
info="an_mail",
),
FormField(
"Funktion im Unternehmen",
FormFieldType.TEXT,
required=False,
key="person_funktion",
readonly=True,
info="an_position",
),
FormField(
"Kontaktweg",
FormFieldType.DROPDOWN,
required=False,
key="kontaktweg",
options=[
("Telefon", None),
("Mail", None),
("Persönliches Gespräch", None),
],
),
]
_INITREC_CONTACT_PERSON = [
FormField(
"Name Unternehmen/Netzwerkpartner",
FormFieldType.TEXT,
key="KP_name_partner",
required=False,
readonly=False,
),
FormField(
"Titel",
FormFieldType.TEXT,
key="KP_titel",
required=False,
),
FormField(
"Anrede",
FormFieldType.DROPDOWN,
key="KP_anrede_anschrift",
required=True,
options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)],
),
FormField(
"Name",
FormFieldType.TEXT,
key="KP_name",
required=True,
),
FormField(
"Vorname",
FormFieldType.TEXT,
key="KP_vorname",
required=False,
),
FormField(
"Festnetznummer",
FormFieldType.TEXT,
key="KP_festnetznummer",
required=False,
),
FormField(
"Mobilfunknummer",
FormFieldType.TEXT,
key="KP_mobilfunknummer",
required=False,
),
FormField(
"E-Mail",
FormFieldType.TEXT,
key="KP_email",
required=False,
),
FormField(
"Funktion/Beziehung zur beratenden Person",
FormFieldType.TEXT,
key="KP_funktion_beziehung",
required=False,
),
FormField(
"Adresse",
FormFieldType.LONGTEXT,
key="KP_adresse",
required=False,
),
]
_INITREC_MASTER_DATA = [
FormField(
"Wie sind Sie auf uns aufmerksam geworden?",
FormFieldType.DROPDOWN,
required=False,
key="kanal_aufmerksamkeit",
options=[
("Agentur für Arbeit", None),
("Ausländerbehörde", None),
("Jobcenter", None),
("Freunde/Familie", None),
("Anerkennungsstelle", None),
("Beratungsstelle", None),
("Internet", None),
("Arbeitgeber", None),
("Bildungsdienstleister", None),
("Welcome-Mappe", None),
("Newsletter WFE", None),
("Newsletter RM", None),
("Sonstiges", None),
],
),
FormField(
"Titel",
FormFieldType.TEXT,
key="titel",
required=False,
),
FormField(
"Anrede",
FormFieldType.DROPDOWN,
key="anrede_anschrift",
required=True,
options=[("Sehr geehrte Frau", None), ("Sehr geehrter Herr", None)],
),
FormField(
"Name",
FormFieldType.TEXT,
key="name",
required=True,
),
FormField(
"Vorname",
FormFieldType.TEXT,
key="vorname",
required=True,
),
FormField(
"Geburtsdatum",
FormFieldType.DATE_FLEXIBLE,
key="geburtsdatum",
required=False,
),
FormField(
"Herkunftsland",
FormFieldType.EXTENDED_DROPDOWN,
key="herkunftsland",
required=True,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField(
"Staatsangehörigkeit",
FormFieldType.EXTENDED_DROPDOWN,
key="staatsangehoerigkeit",
required=False,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField(
"Rückkehrer",
FormFieldType.DROPDOWN,
key="rueckkehrer",
required=False,
options=[("ja", None), ("nein", None)],
),
FormField(
"Wo befindet sich die Person?",
FormFieldType.DROPDOWN,
key="aufenthaltsort",
required=True,
options=[("Inland", None), ("Ausland EU/EWR", None), ("Ausland Drittstaat", None)],
),
FormField(
"Straße",
FormFieldType.TEXT,
key="strasse",
required=False,
),
FormField(
"Hausnummer",
FormFieldType.TEXT,
key="hausnummer",
required=False,
),
FormField(
"PLZ",
FormFieldType.TEXT,
key="PLZ",
required=False,
),
FormField(
"Ort",
FormFieldType.TEXT,
key="ort",
required=False,
),
FormField(
"Bundesland",
FormFieldType.DROPDOWN,
key="bundesland",
required=False,
options=GERMAN_STATE_LIST.for_dropdown,
),
FormField(
"Land",
FormFieldType.EXTENDED_DROPDOWN,
key="land",
required=False,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField(
"Festnetznummer",
FormFieldType.TEXT,
key="festnetznummer",
required=False,
),
FormField(
"Mobilfunknummer",
FormFieldType.TEXT,
key="mobilfunknummer",
required=False,
),
FormField(
"E-Mail",
FormFieldType.TEXT,
key="email",
required=False,
),
FormField(
"Familienstand",
FormFieldType.TEXT,
key="familienstand",
required=False,
),
FormField(
"Anzahl Kinder",
FormFieldType.DYNAMIC_DROPDOWN_NUMERIC,
required=False,
key="anzahl_kinder",
children=[
FormField(
"Anzahl Kinder",
FormFieldType.DROPDOWN,
required=False,
options=[(str(x), None) for x in range(11)],
key="anzahl",
children=[
FormField("Alter Kind", FormFieldType.TEXT, key="alter"),
],
),
],
),
]
_INITREC_ADDITIONAL_DATA = [
FormField(
"Deutsch als Kommunikationssprache",
FormFieldType.DROPDOWN,
required=False,
key="WI_deutsch_sprache",
options=[
("nein", None),
("ja, als Muttersprache", None),
("ja, als Fremdsprache", None),
],
),
FormField(
"Aufenthaltstitel",
FormFieldType.DROPDOWN,
required=False,
key="WI_aufenthaltstitel",
options=[
("anerkannter Flüchtling §§ 22 - 26 AufenthG", None),
("Aufenthaltsgestattung §55 AufenthG", None),
("Blaue Karte EU § 18g AufenthG", None),
("BüMA (Bescheinigung über Meldung als Asylsuchender)", None),
("Duldung § 60 AufenthG", None),
("bisher kein Aufenthaltstitel", None),
("Deutscher", None),
("familiäre Gründe §§ 27 - 36 AufenthG", None),
("Niederlassungserlaubnis §9 AufenthG", None),
("Staatsbürger EUR/EWR/CH", None),
("Aufenthalt für Ausbildung §§ 16 - 17 AufenthG", None),
("Aufenthalt für Erwerbstätigkeit §§ 18- 21 AufenthG", None),
("Chancenaufenthaltsrecht §104c AufenthG", None),
("Sonstiges", None),
],
),
FormField(
"Gültigkeit Aufenthaltsstatus",
FormFieldType.DATE_FLEXIBLE,
required=False,
key="WI_gueltigkeit_aufenthaltstitel",
),
FormField(
"Arbeitsstatus aktuell",
FormFieldType.DROPDOWN,
required=False,
key="WI_arbeitsstatus",
options=[
("Arbeitslos", None),
("Ausbildung/Qualifizierung Inland", None),
("geringfügig beschäftigt", None),
("in Anstellung Inland", None),
("selbstständig Inland", None),
("Ausbildung/Qualifizierung Ausland", None),
("in Anstellung Ausland", None),
("selbstständig Ausland", None),
],
),
FormField(
"Gemeldet bei Institutionen ",
FormFieldType.DROPDOWN,
required=False,
key="WI_meldung_institution",
options=[
("bei keiner", None),
("Jobcenter mit Leistungsbezug", None),
("Jobcenter ohne Leistungsbezug", None),
("Sozialamt mit Leistungsbezug", None),
("Sozialamt ohne Leistungsbezug", None),
("Agentur für Arbeit mit Leistungsbezug", None),
("Agentur für Arbeit ohne Leistungsbezug", None),
],
),
]
_INITREC_SCHOOL = [
FormField("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"),
FormField(
"Abschlussgrad laut Dokument",
FormFieldType.TEXT,
required=False,
key="SB_abschlussgrad",
),
FormField(
"Schule",
FormFieldType.TEXT,
required=False,
key="SB_schule",
),
FormField(
"Ort",
FormFieldType.TEXT,
required=False,
key="SB_ort",
),
FormField(
"Land",
FormFieldType.EXTENDED_DROPDOWN,
key="SB_land",
required=False,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField("Abschlussjahr", FormFieldType.TEXT, required=False, key="SB_abschlussjahr"),
FormField(
"Bemerkungsfeld",
FormFieldType.TEXT,
required=False,
key="SB_bemerkungsfeld",
),
]
_INITREC_HIGHER_EDUCATION = [
FormField(
"Anerkennung",
FormFieldType.TEXT,
required=False,
key="HB_anerkennung",
),
FormField(
"Abschlussgrad",
FormFieldType.TEXT,
required=False,
key="HB_abschlussgrad",
),
FormField(
"Abschlussgrad laut Dokument",
FormFieldType.TEXT,
required=False,
key="HB_abschlussgrad_dokument",
),
FormField(
"Hochschule / Ausbildungsbetrieb / Berufsschule",
FormFieldType.TEXT,
required=False,
key="HB_organisation",
),
FormField(
"Beruf / Fachrichtung",
FormFieldType.TEXT,
required=False,
key="HB_beruf",
),
FormField(
"Land",
FormFieldType.EXTENDED_DROPDOWN,
key="HB_land",
required=False,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField(
"Ort",
FormFieldType.TEXT,
required=False,
key="HB_ort",
),
FormField(
"Abschlussjahr",
FormFieldType.TEXT,
required=False,
key="HB_abschlussjahr",
),
FormField(
"Bemerkungsfeld",
FormFieldType.TEXT,
required=False,
key="HB_bemerkungsfeld",
),
]
_INITREC_WORK_EXPERIENCE = [
FormField(
"Branche",
FormFieldType.DROPDOWN,
required=False,
key="AE_branche",
options=[
(part, None)
for part in (
"Metallerzeugung & -bearbeitung",
"Elektro, Energie, Chemie",
"IT & Software",
"Kunststoff, Papier, Textil",
"Logistik, Verkehr, Transport",
"Handwerk, Bau, Grüne Berufe",
"Gesundheit & Pflege",
"Tourismus & Gastronomie",
"Handel",
"Bildung & Soziales",
"Entwicklung, Planung, Qualität",
"Administration, Finanzen, Verwaltung",
"Marketing, Design, Vertrieb",
"Einkauf, Lager, Wartung",
"Sonstige",
"Keine Schwerpunkte, branchenübergreifende Rekrutierung",
)
],
),
FormField(
"Berufsbezeichnung/Tätigkeit",
FormFieldType.TEXT,
required=False,
key="AE_bezeichnung",
),
FormField(
"Funktion",
FormFieldType.DROPDOWN,
required=False,
key="AE_funktion",
options=[
("Auszubildender", None),
("Fachkraft", None),
("Hilfskraft", None),
("Akademiker", None),
("Führungskraft", None),
("Praktikant", None),
("FSJ/BFD", None),
("Elternzeit", None),
("Sabbatical", None),
("Sonstiges", None),
],
),
FormField(
"Unternehmen",
FormFieldType.TEXT,
required=False,
key="AE_unternehmen",
),
FormField(
"Land",
FormFieldType.EXTENDED_DROPDOWN,
key="AE_land",
required=False,
placeholder="Suche...",
options=COUNTRY_LIST.for_dropdown,
),
FormField(
"Zeitspanne (von ... bis ...)",
FormFieldType.TEXT,
required=False,
key="AE_zeitspanne",
),
FormField(
"Beschäftsigungsart",
FormFieldType.DROPDOWN,
required=False,
key="AE_beschaeftigungsart",
options=[
("Vollzeit", None),
("Teilzeit", None),
("Sonstiges", None),
],
),
FormField(
"Bemerkungsfeld",
FormFieldType.TEXT,
required=False,
key="AE_bemerkungsfeld",
),
]
_INITREC_LANGUAGES = [
FormField(
"Sprache",
FormFieldType.TEXT,
required=False,
key="SP_sprache",
),
FormField(
"Niveau",
FormFieldType.DROPDOWN,
required=False,
key="SP_niveau",
options=[
("A1", None),
("A2", None),
("B1", None),
("B2", None),
("C1", None),
("C2", None),
],
),
FormField(
"Nachweis",
FormFieldType.DYNAMIC_DROPDOWN_OPTION,
key="",
trigger_value="vorhanden",
children=[
FormField(
"Nachweis",
FormFieldType.DROPDOWN,
required=False,
options=[("vorhanden", None), ("nicht vorhanden", None)],
key="SP_nachweis",
children=[
FormField(
"Art des Nachweises",
FormFieldType.TEXT,
required=False,
key="SP_art_nachweis",
),
FormField(
"Datum des Nachweises",
FormFieldType.DATE_FLEXIBLE,
required=False,
key="SP_datum_nachweis",
),
],
),
],
),
]
INITREC_COMP = [
FormField(
"Suche",
FormFieldType.CUSTOM,
custom_widget="grunderfassung_unternehmen_suche",
key="Partnersuche",
children=_INITREC_COMP_SEARCH_HEAD,
),
]
INITREC_METADATEN = [
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,
ignore_get_data=False,
),
FormField(
"Wiedereintrittsdatum",
FormFieldType.DATE_FLEXIBLE,
required=False,
key="Metadaten_wiedereintrittsdatum",
readonly=False,
),
FormField(
"Fallnummer",
FormFieldType.TEXT,
required=True,
key="Grunderfassung_fallnummer",
),
FormField(
"Notizen",
FormFieldType.LONGTEXT,
required=False,
key="Grunderfassung_notiz",
),
]
INITREC_PERSON = [
FormField(
"Status && Projektrelevanz",
FormFieldType.GROUP,
key="Projektrelevanz",
children=[
FormField(
"Projektrelevanz",
FormFieldType.DYNAMIC_DROPDOWN_OPTION,
key="",
trigger_value="ja",
children=[
FormField(
"Relevant perspektivERZ",
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",
),
]
+4894 -1997
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()
+25 -6
View File
@@ -25,17 +25,36 @@ LOGGING_CFG: LoggingConfig = LoggingConfig(
setup_logging(LOGGING_CFG)
logger_base = BASE_LOGGER.getChild("wce_crm")
logger_base.setLevel(logging.DEBUG)
# ** GUI
logger_gui = logger_base.getChild("gui")
logger_gui.setLevel(logging.DEBUG)
logger_search_widget = logger_gui.getChild("search_widget")
logger_search_widget.setLevel(logging.DEBUG)
logger_search_widget.setLevel(logging.WARNING)
logger_get_data = logger_gui.getChild("get_data")
logger_get_data.setLevel(logging.DEBUG)
logger_auto_form = logger_gui.getChild("get_data_auto_form")
logger_auto_form.setLevel(logging.DEBUG)
logger_get_data.setLevel(logging.WARNING)
logger_custom_fields = logger_gui.getChild("custom_fields")
logger_custom_fields.setLevel(logging.WARNING)
logger_page_consulting = logger_gui.getChild("page_consulting")
logger_page_consulting.setLevel(logging.DEBUG)
logger_page_initrec = logger_gui.getChild("page_initrec")
logger_page_initrec.setLevel(logging.DEBUG)
logger_initrecform = logger_gui.getChild("initrecform")
logger_initrecform.setLevel(logging.WARNING)
logger_prototyping = logger_gui.getChild("prototyping")
logger_prototyping.setLevel(logging.DEBUG)
# ** Backend
logger_back = logger_base.getChild("backend")
logger_back.setLevel(logging.DEBUG)
logger_backend = logger_base.getChild("backend")
logger_backend.setLevel(logging.WARNING)
logger_initrec_docs = logger_base.getChild("initrec_docs")
logger_initrec_docs.setLevel(logging.DEBUG)
+269 -1
View File
@@ -1,9 +1,277 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
import copy
import dataclasses as dc
import datetime
import enum
from collections.abc import Generator
from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict
if TYPE_CHECKING:
import polars as pl
UserId: TypeAlias = int
RecId: TypeAlias = int
ConsId: TypeAlias = int
IntLinkId: TypeAlias = int
ExtMaId: TypeAlias = int
ExtAnId: TypeAlias = int
PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]]
# TODO rename: these are not entity types or entities but different forms of
# initial recordings
class RecordingType(enum.IntEnum):
WITH_COMPANY = enum.auto()
WITHOUT_COMPANY = enum.auto()
class EntityType(enum.IntEnum):
METADATA = enum.auto()
COMPANY = enum.auto()
PERSON = enum.auto()
class ConsultingType(enum.StrEnum):
PAUSCHAL = enum.auto()
INDIVIDUAL = enum.auto()
@dc.dataclass(slots=True, kw_only=True)
class EntityIds:
un_id: ExtMaId | None = None
an_id: ExtAnId | None = None
pers_id: RecId | None = None
link_id: IntLinkId | None = None
_field_names: frozenset[str] = dc.field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
field_names = set(f.name for f in dc.fields(EntityIds))
field_names.remove("_field_names")
self._field_names = frozenset(field_names)
def set_by_name(
self,
attr_name: str,
value: int | None,
) -> None:
if attr_name not in self._field_names:
raise KeyError(
f"Attribute {attr_name} not member of this class. Valid "
f"options: {self._field_names}"
)
setattr(self, attr_name, value)
def update(
self,
ids: EntityIds,
ignore_none: bool = False,
deep: bool = False,
) -> None:
if not isinstance(ids, EntityIds):
raise TypeError("New IDs must be have the same type as this object")
for field in dc.fields(ids):
new_value = getattr(ids, field.name)
if ignore_none and new_value is None:
continue
if deep and new_value is not None:
new_value = copy.deepcopy(new_value)
setattr(self, field.name, new_value)
def _get_relevant_fields(
self,
ent_type: EntityType | None = None,
) -> Generator[int | None, None, None]:
if ent_type is None:
relevant_fields = (getattr(self, field_name) for field_name in self._field_names)
elif ent_type is EntityType.COMPANY:
# RELEVANT_ATTRIBUTES = {"un_id", "an_id", "link_id"}
RELEVANT_ATTRIBUTES = {"un_id", "an_id"}
relevant_fields = (
getattr(self, field_name)
for field_name in self._field_names
if field_name in RELEVANT_ATTRIBUTES
)
elif ent_type is EntityType.PERSON:
RELEVANT_ATTRIBUTES = {"pers_id"}
relevant_fields = (
getattr(self, field_name)
for field_name in self._field_names
if field_name in RELEVANT_ATTRIBUTES
)
elif ent_type is EntityType.METADATA:
RELEVANT_ATTRIBUTES = set()
relevant_fields = (
getattr(self, field_name)
for field_name in self._field_names
if field_name in RELEVANT_ATTRIBUTES
)
else:
raise RuntimeError(f"Unspecified entity type {ent_type}")
return relevant_fields
def empty(
self,
ent_type: EntityType | None = None,
) -> bool:
relevant_fields = self._get_relevant_fields(ent_type=ent_type)
return not any(relevant_fields)
def valid(
self,
ent_type: EntityType | None = None,
) -> bool:
relevant_fields = self._get_relevant_fields(ent_type=ent_type)
return all(relevant_fields)
# @dc.dataclass(slots=True, kw_only=True)
# class EntityIds:
# un_id: RecId | None = None
# pers_id: RecId | None = None
# type: dc.InitVar[RecordingType | None] = None
# _rec_type: RecordingType | None = dc.field(init=False)
# def __post_init__(self, type: RecordingType | None) -> None:
# self._rec_type = type
# @property
# def rec_type(self) -> RecordingType | None:
# return self._rec_type
# def any(self) -> bool:
# return self.un_id is not None or self.pers_id is not None
# def all(self) -> bool:
# return self.un_id is not None and self.pers_id is not None
# def valid(self) -> bool:
# if self.rec_type is None:
# raise TypeError("Initrec type not defined. Can not determine valid state.")
# elif self.rec_type is RecordingType.COMPANY:
# return self.all()
# elif self.rec_type is RecordingType.PERSON:
# return self.any()
# else:
# raise RuntimeError(
# f"Unknown initrec type {self.rec_type}. This is not supported."
# )
# def set_id(
# self,
# id_: RecId | None,
# ent_type: RecordingType,
# ) -> None:
# if ent_type is RecordingType.COMPANY:
# self.un_id = id_
# elif ent_type is RecordingType.PERSON:
# self.pers_id = id_
# def get_id(
# self,
# ent_type: RecordingType,
# ) -> RecId | None:
# if ent_type is RecordingType.COMPANY:
# return self.un_id
# elif ent_type is RecordingType.PERSON:
# return self.pers_id
# // database interaction data structures
# ** externals
class CompanyInfo(TypedDict):
ma_id: str
wce_id: str
ma_unternehmensname: str
ma_branche: str
ma_strasse: str
ma_hausnummer: str
ma_plz: str
ma_ort: str
ma_plz_postfach: str
ma_postfach: str
ma_website: str
ma_mail: str
ma_telefonnummer: str
ma_faxnummer: str
ma_ersteintrag_datum: str
ma_aktualisierung_datum: str
ma_aktualisierung_nutzer: str
ma_sollprozess: str
ma_auslaendische_mitarbeiter: str
ma_quelle_information: str
ma_bemerkung: str
ma_kontakt: str
ma_schlagworte: str
ma_archiviert: str
class ContactPersonInfo(TypedDict):
an_id: str
ma_id: str
wce_id: str
st_id: str
an_sachgebiet: str
an_anrede: str
an_titel: str
an_nachname: str
an_vorname: str
an_position: str
an_mail: str
an_festnetz: str
an_mobil: str
an_faxnummer: str
an_hauptansprechpartner: str
an_anrede_anschrift: str
an_bemerkung: str
an_aktualisierung_datum: str
an_aktualisierung_nutzer: str
an_letztes_kontaktdatum: str
an_ersteintrag_datum: str
an_archiviert: str
# ** internals
@dc.dataclass(slots=True)
class MainPageEntry:
ids: EntityIds
display_name: str
dt_updated: datetime.datetime
recording_type: RecordingType
@dc.dataclass(slots=True)
class CompanyProfileConsultationEntry:
title: str
cons_id: ConsId
date_updated: datetime.datetime
cons_type: ConsultingType
@dc.dataclass(slots=True)
class CompanyProfileConsultations:
pauschal: list[CompanyProfileConsultationEntry]
individual: list[CompanyProfileConsultationEntry]
@dc.dataclass(slots=True)
class LinkingConsultationsEntryCompany:
un_id: RecId
display_name: str
@dc.dataclass(slots=True)
class LinkingConsultationsEntryPerson:
pers_id: RecId
un_ids: set[RecId]
display_name: str