Initial External Test 29.05.2026 #1

Merged
foefl merged 10 commits from dev into main 2026-05-29 11:06:10 +00:00
13 changed files with 185 additions and 49 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ reports/
CREDENTIALS*
*.pkl
*.pickle
# docs
*.pdf
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -1,3 +1,25 @@
# WCE: NAFKA --- GUI
# WCE/NAFKA: CRM GUI
...
## Allgemeine Hinweise
Die vorliegende Anwendung befindet sich nach aktuellem Stand (Mai 2026) noch in einem sehr frühen Teststadium. Weder das Programmverhalten noch das Erscheinungsbild sind repräsentativ für das angestrebte Endergebnis. Es kann beim Testen zu Fehlern und Abstürzen kommen. Die Anwendung schreibt zwar selbstständig Fehlerlogs, dennoch ist es in Absturzfällen hilfreich, wenn hierzu ein kurzer Fehlerbericht erstellt wird. Dieser sollte die Schritte beinhalten, die durch den Nutzer erfolgt sind, damit der Fehler reproduziert und schlussendlich behoben werden kann.
## Aktuelle Einschränkungen
Dem Programm liegt aktuell ein hitorische Kopie der Kontaktliste vor, die für die Verknüpfung von Unternehmensdaten genutzt wird. Das bedeutet, dass sich Änderungen an der tatsächlichen, aktuell verwendeten Kontaktliste *nicht* in der Anwendung widerspiegeln. Die Anbindung an die in Betrieb befindliche Kontaktliste wird noch vorgenommen.
Eine Nutzerverwatung gibt es aktuell noch nicht. Dementsprechend wird in der Anwendung auch nicht gespeichert, von wem die Änderungen zuletzt durchgeführt wurden. Ein Nutzerfeld existiert in der Oberfläche zwar, wird aber nicht gefüllt und bleibt aktuell noch stets leer.
## Start der Anwendung
Die Anwendung besteht aus einem Ordner, der alle relevanten Dateien zu ihrer Ausführung beinhaltet. Wenn der Ordner geöffnet wird, befinden sich darin die benötigten Unterordner und eine *Batch-Datei* (Endung ".bat"). Mit einem Doppelklick auf diese Datei, wird die Anwendung gestartet. Dies kann einige Sekunden in Anspruch nehmen. Sollte die Anwendung nach mehr als zehn Sekunden nicht gestartet sein, so wird vermutlich die Ausführung blockiert. In diesem Fall sind Ausführungsrichtlinien und Anti-Virus-Software durch die Administration zu prüfen. Sollte die Anwendung auch nach Tätigkeit der Administration nicht starten, so ist bitte *Kontakt zu d-opt* aufzunehmen, damit das Problem behoben werden kann.
## 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.
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.*

5
docs/Versionshistorie.md Normal file
View File

@@ -0,0 +1,5 @@
# Versionshistorie (Changelog)
## 29.05.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260529)
- initiale Version für erste Feedback-Schleife

8
pdm.lock generated
View File

@@ -5,7 +5,7 @@
groups = ["default", "dev", "lint", "nb", "tests"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:4b3f779c4a10819bdc84e071dbf4016edd7429afeb4697b5ac7e622b3c870d87"
content_hash = "sha256:25812ee6ba42033e1341c1799716828d8582092826c1d0889ea775a0f5c548a2"
[[metadata.targets]]
requires_python = ">=3.11,<3.14"
@@ -975,7 +975,7 @@ files = [
[[package]]
name = "dopt-basics"
version = "0.2.5"
version = "0.2.6"
requires_python = ">=3.11"
summary = "basic cross-project tools for Python-based d-opt projects"
groups = ["default"]
@@ -983,8 +983,8 @@ dependencies = [
"tzdata>=2025.1",
]
files = [
{file = "dopt_basics-0.2.5-py3-none-any.whl", hash = "sha256:e5a920054463af782ff05526d2ae726d2d3533d7a6419a0f1edeee43b0118ca5"},
{file = "dopt_basics-0.2.5.tar.gz", hash = "sha256:3f26a1c2d9557c41ec9b6452ee0e2cfd256e64160386708a2f69b2e36b403ad3"},
{file = "dopt_basics-0.2.6-py3-none-any.whl", hash = "sha256:f0818e2f83e91fb7d398bcabfc6c420159757d7d093b20574b88a3abc24e3eab"},
{file = "dopt_basics-0.2.6.tar.gz", hash = "sha256:0e90d0d7a711e0dee9f898574683442644d3145ac8905d38ea23775f62aa5d2b"},
]
[[package]]

View File

@@ -1,11 +1,11 @@
[project]
name = "wce-crm"
version = "0.1.1dev4"
version = "0.1.1dev7"
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.5", "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"]
requires-python = "<3.14,>=3.11"
readme = "README.md"
license = {text = "LicenseRef-Proprietary"}
@@ -71,7 +71,7 @@ directory = "reports/coverage"
[tool.bumpversion]
current_version = "0.1.1dev4"
current_version = "0.1.1dev7"
parse = """(?x)
(?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\.

View File

@@ -8,6 +8,7 @@ $ENV_PATH = 'B:\deployments\WCE-NAFKA\dopt_nafka_wce-crm'
$PY_PATH = Join-Path -Path $ENV_PATH -ChildPath 'python'
$SRC_PATH = (Get-Location).Path
Write-Output "Build Pipeline for d-opt WCE/NAFKA project"
Write-Output "Delete existing artifacts..."
@@ -41,23 +42,23 @@ if ($? -eq $false){
}
Write-Output "Built package successfully"
# manual
# Write-Output "Generate manual..."
# .\scripts\cvt_manual.ps1
# if ($? -eq $false){
# Write-Output "[PWSH] Exiting script because there errors while generating the README file"
# Exit
# }
# Write-Output "Generated manual file successfully"
# Write-Output "Copying manual file..."
# $readme_src_path = Join-Path -Path $SRC_PATH -ChildPath 'docs\manual.pdf'
# $readme_dest_path = Join-Path -Path $ENV_PATH -ChildPath 'manual'
# Copy-Item -Path $readme_src_path -Destination $readme_dest_path -Force
# if ($? -eq $false){
# Write-Output "[PWSH] Exiting script because there were errors while copying the manual file"
# Exit
# }
# Write-Output "Copied manual file successfully"
# documentation
Write-Output "Generate docs..."
# $doc_build_script = Join-Path -Path $SRC_PATH -ChildPath "scripts\cvt_docs.ps1" -Resolve
.\scripts\cvt_docs.ps1
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there errors while generating the doc files"
Exit
}
Write-Output "Generated doc files successfully"
Write-Output "Copying doc files..."
$docs_src_path = Join-Path -Path $SRC_PATH -ChildPath 'docs\*.pdf'
Copy-Item -Path $docs_src_path -Destination $ENV_PATH -Force
if ($? -eq $false){
Write-Output "[PWSH] Exiting script because there were errors while copying the doc files"
Exit
}
Write-Output "Copied doc files successfully"
Write-Output "Go into env directory..."

3
scripts/cvt_docs.ps1 Normal file
View File

@@ -0,0 +1,3 @@
# convert README Markdown file to PDF as a manual
pandoc .\README.md -o .\docs\01_Kurzanleitung.pdf -V geometry:"a4paper, margin=2.5cm" -V header-includes="\usepackage[none]{hyphenat}"
pandoc .\docs\Versionshistorie.md -o .\docs\02_Versionshistorie.pdf -V geometry:"a4paper, margin=2.5cm" -V header-includes="\usepackage[none]{hyphenat}"

7
src/wce_crm/README.md Normal file
View File

@@ -0,0 +1,7 @@
# 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

View File

@@ -8,6 +8,7 @@ import polars as pl
import sqlalchemy as sql
from wce_crm import db
from wce_crm.logging import logger_back as logger
class CompanyInfo(TypedDict):
@@ -83,6 +84,7 @@ def _transform_for_gui_output(
def comp_search_choices() -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
logger.debug("[Call backend] comp_search_choices")
q = db.DF_CRM_MASTER.lazy()
counter = pl.int_range(0, pl.len()).over(pl.col.ma_unternehmensname)
q = q.with_columns(
@@ -98,6 +100,7 @@ def comp_search_choices() -> tuple[tuple[str, int], ...]:
def comp_search_get_info(
ma_id: int,
) -> CompanyInfo:
logger.debug("[Call backend] comp_search_get_info")
df = db.DF_CRM_MASTER.filter(pl.col.ma_id == ma_id)
if df.height > 1 or df.height == 0:
raise ValueError(f"Größe des zurückgelieferten Datenpakets ungültig: {df.height}")
@@ -111,6 +114,7 @@ def contact_person_search_choices(
use_both_names: bool,
) -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
logger.debug("[Call backend] contact_person_search_choices")
q = db.DF_CONTACT_PERSON.lazy()
if ma_id is not None:
q = q.filter(pl.col.ma_id == ma_id)
@@ -136,6 +140,7 @@ def contact_person_search_choices(
def contact_person_search_get_info(
an_id: int,
) -> ContactPersonInfo:
logger.debug("[Call backend] contact_person_search_get_info")
df = db.DF_CONTACT_PERSON.filter(pl.col.an_id == an_id)
if df.height > 1 or df.height == 0:
raise ValueError(f"Größe des zurückgelieferten Datenpakets ungültig: {df.height}")
@@ -147,6 +152,7 @@ def contact_person_search_get_info(
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)
@@ -156,6 +162,7 @@ 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_)
@@ -168,6 +175,7 @@ def update_initial_recording(
def get_initial_recording(
id_: int,
) -> dict[str, Any]:
logger.debug("[Call backend] get_initial_recording")
stmt = db.grunderfassung_unternehmen.select().where(
db.grunderfassung_unternehmen.c.erfassung_id == id_
)
@@ -190,6 +198,7 @@ class FrontpageCompany:
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,

View File

@@ -357,7 +357,9 @@ grunderfassung_unternehmen: sql.Table = Table(
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),
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),

View File

@@ -1,5 +0,0 @@
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

View File

@@ -8,6 +8,7 @@ import json
import pickle
import re
import sys
import traceback
import uuid
from collections import defaultdict
from collections.abc import Container, Iterable, Sequence
@@ -33,7 +34,9 @@ from PySide6.QtCore import (
QObject,
Qt,
QTimer,
QtMsgType,
Signal,
qInstallMessageHandler,
)
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
@@ -364,14 +367,26 @@ def get_leaf_dicts(data):
def pprint_registry(widget_registry: WidgetRegistry) -> None:
print("---\n\n>>> Widget registry:")
print("\n\n>>> Widget registry:")
for key, entry in widget_registry.items():
print(f"Key: {key}")
print(f"\twidget: {entry['widget']}")
print(f"\tfield key: {entry['form_field'].key}")
print(f"\twidget: {entry['widget']}")
print(f"\tfield key: {entry['form_field'].key}")
print(f"\tfield type: {entry['form_field'].type}")
def pformat_registry(widget_registry: WidgetRegistry) -> str:
lines: list[str] = []
lines.append("\n\n>>> Widget registry:")
for key, entry in widget_registry.items():
lines.append(f"Key: {key}")
lines.append(f"\twidget: {entry['widget']}")
lines.append(f"\tfield key: {entry['form_field'].key}")
lines.append(f"\tfield type: {entry['form_field'].type}")
return "\n".join(lines)
class FormFieldType(enum.StrEnum):
GROUP = enum.auto()
TEXT = enum.auto()
@@ -1073,11 +1088,11 @@ def set_form_data(
value = data
else:
if key not in data:
logger_gui.debug("---- Key not in data: %s")
logger_gui.debug("-------- Data: %s", pformat(data))
logger_gui.error("Key not in data: %s", key)
logger_gui.error("Data:\n%s", pformat(data))
value = data[key]
logger_gui.debug("---- Key set: %s", key)
logger_gui.debug("Key set: %s", key)
set_widget_value(widget, value)
@@ -1716,6 +1731,11 @@ class AutoForm(QWidget):
self.current_id: int = -1
logger_auto_form.debug(
"Initialised Auto Form Widget. Registry:%s",
pformat_registry(self.widget_registry),
)
def _print_registry(self) -> None:
pprint_registry(self.widget_registry)
@@ -2472,7 +2492,9 @@ class NewEntrySelect_view(QWidget):
btn_person.setFixedWidth(300)
btn_person.setFixedHeight(40)
btn_person.clicked.connect(lambda: self.person_requested.emit())
btn_person.clicked.connect(lambda: print("Person gewählt"))
btn_person.clicked.connect(
lambda: logger_gui.info("[Grunderfassung: Dummy Individualperson] Person gewählt")
)
layout.addWidget(back_btn)
layout.addSpacing(15)
@@ -2631,7 +2653,7 @@ FORM_FIELDS_SEARCH_HEAD = [
FORM_FIELDS_CONTACT_PERSON = [
FormField(
"Name Unternehmen/Netzwerkpartner (pre-filled von Suche)",
"Name Unternehmen/Netzwerkpartner (vorausgefüllt von Suche)",
FormFieldType.TEXT,
key="KP_name_partner",
required=False,
@@ -3308,7 +3330,6 @@ CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig(
)
# TODO clean code
class PageFormCompany(QWidget):
back_main_requested = Signal() # back to main page
back_requested = Signal() # back button
@@ -3381,7 +3402,23 @@ class PageFormCompany(QWidget):
container_layout.addSpacing(15)
# --- CUSTOM LOGIC ---
# 'Bundesland' only if 'Inland' selected in 'Stammdaten'
# ** fill 'Kontaktperson -> Namen Unternehmen'
search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche")
assert len(search_res) == 1
search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"])
self.search_widget_trigger = cast(
QLineEdit, search_widget.company_widgets["ma_unternehmensname"]
)
text_widget_set = search_widgets_by_key(
self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner"
)
assert len(text_widget_set) == 1
self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"])
self.search_widget_trigger.textChanged.connect(
self._custom_set_company_name_contact_person
)
# ** 'Bundesland' only if 'Inland' selected in 'Stammdaten'
person_location = search_widgets_by_key(
self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort"
)
@@ -3399,6 +3436,9 @@ class PageFormCompany(QWidget):
self.selection_county.setProperty("styleClass", "stempel")
self.selection_county.setEnabled(False)
def _custom_set_company_name_contact_person(self, value: str) -> None:
self.text_widget_set.setText(str(value))
def _custom_county_selection(self, idx: int) -> None:
value = self.person_location.itemData(idx)
if value == "Inland":
@@ -3634,9 +3674,51 @@ class MainWindow(QMainWindow):
help_menu.addAction(about_action)
# ** global exception handling
def global_exception_handler(exc_type, exc_value, exc_traceback):
"""catches all unhandled errors"""
# format error
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
logger_gui.critical(f"UNEXPECTED ERROR:\n{error_msg}")
# message to user
# check if QApplication exists otherwise crashing of crash handler possible)
if QApplication.instance():
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle("Kritischer Fehler")
msg_box.setText(
"Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert."
)
# details for user or screenshots
msg_box.setDetailedText(error_msg)
msg_box.exec()
sys.exit(1)
def qt_message_handler(mode, context, message):
"""forwards internal Qt C++ warnings to Python loggers"""
if mode == QtMsgType.QtInfoMsg:
logger_gui.info(message)
elif mode == QtMsgType.QtWarningMsg:
logger_gui.warning(message)
elif mode == QtMsgType.QtCriticalMsg:
logger_gui.error(message)
elif mode == QtMsgType.QtFatalMsg:
logger_gui.critical(message)
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyleSheet(QSS)
window = MainWindow()
window.show()
sys.exit(app.exec())
sys.excepthook = global_exception_handler
qInstallMessageHandler(qt_message_handler)
try:
app = QApplication(sys.argv)
app.setStyleSheet(QSS)
window = MainWindow()
window.show()
sys.exit(app.exec())
except Exception as err:
logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True)

View File

@@ -1,6 +1,6 @@
import logging
from dopt_basics.logging import BASE_LOGGER, setup_logging
from dopt_basics.logging import BASE_LOGGER, LoggingConfig, setup_logging
from wce_crm.constants import Config
@@ -14,12 +14,16 @@ if Config.DEVELOPMENT_STATE:
if not Config.PATH_LOGGING.exists():
Config.PATH_LOGGING.mkdir()
setup_logging(
LOGGING_CFG: LoggingConfig = LoggingConfig(
enable_stderr=enable_stderr,
enable_file=enable_file,
logging_dir=Config.PATH_LOGGING,
log_filename=Config.LOG_FILENAME,
file_max_bytes=10_485_760,
file_backup_count=2,
)
setup_logging(LOGGING_CFG)
logger_base = BASE_LOGGER.getChild("wce_crm")
# ** GUI
@@ -31,3 +35,7 @@ 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)
# ** Backend
logger_back = logger_base.getChild("backend")
logger_back.setLevel(logging.DEBUG)