from __future__ import annotations
import copy
import dataclasses as dc
import datetime
import re
import sys
import traceback
from collections import defaultdict
from collections.abc import Callable, Container, Iterable, Sequence
from pprint import pformat
from typing import (
TYPE_CHECKING,
Any,
Final,
Literal,
Protocol,
TypeAlias,
TypedDict,
TypeVar,
cast,
)
from typing_extensions import override
from dopt_basics.result_pattern import STATUS_HANDLER
from pydantic import (
ValidationError,
)
from PySide6.QtCore import (
QDate,
QDateTime,
QEvent,
QKeyCombination,
QLibraryInfo,
QLocale,
QObject,
Qt,
QTimer,
QtMsgType,
QTranslator,
Signal,
qInstallMessageHandler,
)
from PySide6.QtGui import QAction, QFont, QKeySequence
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QBoxLayout,
QButtonGroup,
QCalendarWidget,
QComboBox,
QCompleter,
QDateEdit,
QDialog,
QDialogButtonBox,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
import wce_crm.constants
from wce_crm import types
from wce_crm.backend import backend
from wce_crm.constants import TIMEZONE_CEST
from wce_crm.data_models import (
COLUMN_SEP,
INITREC_CONFIGS,
Beratungsgespraech_Einzelgespraech,
Beratungsgespraech_Vorgang,
InitRec,
Page_CompanyProfile_State,
Page_Consulting_ConsultingSession_State,
Page_Consulting_Linking_State,
Page_Consulting_State,
Page_Consulting_Table_State,
Page_InitRec_Form_Data,
Page_InitRec_Form_State,
Page_InitRec_State,
Page_MainPage_State,
Page_NewEntry_State,
Session,
set_page_state,
translate_pydantic_errors,
)
from wce_crm.form_defs import (
FormField,
FormFieldType,
)
from wce_crm.logging import (
logger_custom_fields,
logger_get_data,
logger_gui,
logger_initrecform,
logger_page_consulting,
logger_page_initrec,
)
from wce_crm.types import (
CompanyProfileConsultationEntry,
ConsultingType,
EntityIds,
EntityType,
RecId,
RecordingType,
)
if TYPE_CHECKING:
from wce_crm.data_models import (
Module,
Page_InitRec_Form_State,
WrapperModule,
)
from wce_crm.types import (
ConsId,
ExtMaId,
LinkingConsultationsEntryCompany,
LinkingConsultationsEntryPerson,
)
K = TypeVar("K")
V = TypeVar("V")
T = TypeVar("T")
W = TypeVar("W", bound="QWidget")
DEBUG: bool = True
DEBUG_SEARCH_WIDGET: bool = False
DEBUG_NO_DATABASE: bool = False
DEBUG_GET_SET: bool = False
# fallback if deployed: disable all debug flags
if not wce_crm.constants.Config.DEVELOPMENT_STATE:
DEBUG = False
DEBUG_SEARCH_WIDGET = False
DEBUG_NO_DATABASE = False
DEBUG_GET_SET = False
QSS = """
*[styleClass="stempel"] {
background-color: #f1f5f9;
color: #333D4B;
border: 1px dashed #cbd5e1;
border-radius: 4px;
padding: 5px;
}
*[styleClass="stempel"]:focus {
border: 1px solid #cbd5e1;
}
QPushButton[styleClass="addButton"] {
background-color: #007acc;
color: white;
border: none;
border-radius: 6px;
padding: 8px 15px;
font-weight: bold;
}
QPushButton[styleClass="addButton"]:hover {
background-color: #0098ff;
}
QPushButton[styleClass="addButton"]:disabled {
background-color: #cbd5e1;
color: #94a3b8;
}
QPushButton[styleClass="outlineAddButton"] {
background-color: transparent;
color: #007acc;
border: 1px solid #007acc;
border-radius: 6px;
padding: 5px 12px;
font-weight: bold;
}
QPushButton[styleClass="outlineAddButton"]:hover {
background-color: #007acc;
color: white;
}
QPushButton[styleClass="outlineAddButton"]:disabled {
/* background-color: #cbd5e1; */
background-color: transparent;
color: #94a3b8;
border: none;
}
QPushButton[styleClass="softAddButton"] {
background-color: #e0f2fe;
color: #0369a1;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-weight: bold;
}
QPushButton[styleClass="softAddButton"]:hover {
background-color: #bae6fd;
color: #0369a1;
}
QPushButton[styleClass="flatAddButton"] {
background-color: transparent;
color: #475569;
border: none;
padding: 6px 12px;
font-weight: bold;
}
QPushButton[styleClass="flatAddButton"]:hover {
background-color: #f1f5f9;
color: #007acc;
}
"""
DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---"
DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]")
DATETIME_FMT: Final[str] = "%d.%m.%Y %H:%M:%S"
DATE_FMT: Final[str] = "%d.%m.%Y"
DEFAULT_READONLY_STYLECLASS: Final[str] = "stempel"
def merge_dicts_to_lists(
dict_iter: Iterable[dict[K, V]],
) -> dict[K, list[V]]:
merged = defaultdict(list)
for d in dict_iter:
for key, value in d.items():
merged[key].append(value)
return dict(merged)
def unmerge_dict_to_list(
merged_dict: dict[K, list[V]],
) -> list[dict[K, V]]:
if not merged_dict:
return []
keys = merged_dict.keys()
value_lists = merged_dict.values()
return [dict(zip(keys, row)) for row in zip(*value_lists)]
def pprint_registry(widget_registry: WidgetRegistry) -> None:
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"\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 CustomForm(Protocol):
def get_form_data(self) -> dict[str, Any] | list[dict[str, Any]]: ...
def set_form_data(self, data: Any) -> None: ...
def reset(self) -> None: ...
def validate(self) -> list[str]: ...
def lock(self) -> None: ...
def unlock(self) -> None: ...
class CustomFormWidget(QWidget):
def __init__(
self,
form_fields: Sequence[FormField],
label: str = "Suche",
prefix: str = "",
) -> None:
super().__init__()
self.form_fields = form_fields
self.label = label
self.prefix = prefix
def get_form_data(self) -> dict[str, Any]: ...
def set_form_data(self, data: Any) -> None: ...
def reset(self) -> None: ...
def validate(self) -> list[str]: ...
def lock(self) -> None: ...
def unlock(self) -> None: ...
def _add_widget_to_layout(
form_field: FormField[W],
widget: QWidget,
target_layout: QLayout,
widget_only: bool = False,
) -> None:
if isinstance(target_layout, QFormLayout):
if widget_only:
target_layout.addRow(widget)
elif form_field.tooltip:
tooltip_layout = _add_tooltip(widget, form_field.tooltip)
target_layout.addRow(form_field.label, tooltip_layout)
else:
target_layout.addRow(form_field.label, widget)
elif isinstance(target_layout, QBoxLayout):
if widget_only:
target_layout.addWidget(widget)
elif form_field.tooltip:
tooltip_layout = _add_tooltip(widget, form_field.tooltip)
target_layout.addLayout(tooltip_layout)
else:
target_layout.addWidget(widget)
def build_and_add_widget(
form_field: FormField[W],
parent_layout: QLayout,
widget_registry: WidgetRegistry | None = None,
prefix: str = "",
) -> tuple[str, W]:
no_scroll_filter = NoScrollFilter(parent_layout)
if prefix and form_field.key:
full_key = f"{prefix}{COLUMN_SEP}{form_field.key}" if prefix else form_field.key
elif prefix and not form_field.key:
full_key = f"{prefix}"
else:
full_key = form_field.key
layout_widget_only: bool = False
install_no_scroll_filter: bool = True
match form_field.type:
case (
FormFieldType.DYNAMIC_LIST
| FormFieldType.DYNAMIC_DROPDOWN_NUMERIC
| FormFieldType.DYNAMIC_DROPDOWN_OPTION
| FormFieldType.CUSTOM
):
layout_widget_only = True
install_no_scroll_filter = False
match form_field.type:
case FormFieldType.GROUP:
raise TypeError("FormField Groups are not supported for direct building")
case FormFieldType.TEXT | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME:
widget = QLineEdit()
if form_field.placeholder:
widget.setPlaceholderText(form_field.placeholder)
if form_field.fill_value:
widget.setText(form_field.fill_value)
if form_field.readonly:
widget.setReadOnly(True)
widget.setProperty("styleClass", "stempel")
case FormFieldType.TEXT_SEARCH:
widget = QLineEdit()
if form_field.placeholder:
widget.setPlaceholderText(form_field.placeholder)
else:
widget.setPlaceholderText("Tippen zum Suchen...")
search_completer = QCompleter()
search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
search_completer.setFilterMode(Qt.MatchFlag.MatchContains)
widget.setCompleter(search_completer)
case FormFieldType.LONGTEXT:
widget = QPlainTextEdit()
widget.setMaximumHeight(80)
if form_field.placeholder:
widget.setPlaceholderText(form_field.placeholder)
if form_field.readonly:
widget.setReadOnly(True)
widget.setProperty("styleClass", "stempel")
case FormFieldType.DATE:
widget = QDateEdit()
widget.setCalendarPopup(True)
cal = widget.calendarWidget()
if cal:
cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday)
cal.setGridVisible(True)
cal.setStyleSheet("""
QCalendarWidget QAbstractItemView {
selection-background-color: #2980b9;
selection-color: white;
}
""")
if form_field.fill_value:
set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.setDate(set_date)
else:
widget.setDate(QDate.currentDate())
if form_field.readonly:
widget = QLineEdit()
widget.setReadOnly(True)
widget.setProperty("styleClass", "stempel")
if form_field.fill_value:
widget.setText(form_field.fill_value)
case FormFieldType.DATE_FLEXIBLE:
widget = FlexibleDateInput()
if form_field.fill_value:
set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_date(set_date)
if form_field.readonly:
widget.setReadOnly(True)
if form_field.required:
widget.setRequired(True)
case FormFieldType.DATETIME_FLEXIBLE:
widget = FlexibleDateTimeInput()
if form_field.fill_value:
set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm")
if not set_datetime.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_datetime(set_datetime)
if form_field.readonly:
widget.setReadOnly(True)
if form_field.required:
widget.setRequired(True)
case FormFieldType.DROPDOWN:
widget = QComboBox()
widget.addItem(DROPDOWN_DEFAULT, None)
for option in form_field.dropdown_options:
widget.addItem(option.label, option.data)
if form_field.placeholder:
widget.setPlaceholderText(form_field.placeholder)
if form_field.fill_value:
widget.setCurrentText(form_field.fill_value)
else:
widget.setCurrentIndex(0)
if form_field.readonly:
widget.setEnabled(False)
widget.setProperty("styleClass", "stempel")
case FormFieldType.EXTENDED_DROPDOWN:
widget = QComboBox()
widget.setEditable(True)
widget.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
completer = widget.completer()
assert completer
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setFilterMode(Qt.MatchFlag.MatchContains)
completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
widget.addItem(DROPDOWN_DEFAULT, None)
for option in form_field.dropdown_options:
widget.addItem(option.label, option.data)
if form_field.placeholder:
line_edit = widget.lineEdit()
assert line_edit
line_edit.setPlaceholderText(form_field.placeholder)
if form_field.fill_value:
widget.setCurrentText(form_field.fill_value)
else:
widget.setCurrentIndex(-1)
if form_field.readonly:
widget.setEnabled(False)
widget.setProperty("styleClass", "stempel")
case FormFieldType.DYNAMIC_LIST:
widget = DynamicListWidget(
form_field.children,
form_field.label,
prefix=f"{full_key}",
)
case FormFieldType.DYNAMIC_DROPDOWN_NUMERIC:
widget = DynamicDropdownWidgetNumeric(
form_field.children,
form_field.label,
prefix=f"{full_key}",
)
case FormFieldType.DYNAMIC_DROPDOWN_OPTION:
widget = DynamicDropdownWidgetOption(
form_field.children,
form_field.trigger_value,
form_field.label,
prefix=f"{full_key}",
)
case FormFieldType.CUSTOM:
widget_class = CUSTOM_WIDGETS[form_field.custom_widget]
widget = widget_class(
form_fields=form_field.children,
label=form_field.label,
prefix=f"{full_key}",
)
case _:
raise NotImplementedError(f"Not supported field type: {form_field.type.value}")
if install_no_scroll_filter:
widget.installEventFilter(no_scroll_filter)
_add_widget_to_layout(form_field, widget, parent_layout, widget_only=layout_widget_only)
if widget_registry is not None:
widget_registry[full_key] = {
"widget": widget,
"form_field": form_field,
}
return full_key, cast(W, widget)
def _build_ui_recursively(
schema: Sequence[FormField],
parent_layout: QFormLayout,
widget_registry: WidgetRegistry,
prefix: str = "",
) -> list[str]:
keys: list[str] = []
for field in schema:
if prefix and field.key:
full_key = f"{prefix}{COLUMN_SEP}{field.key}" if prefix else field.key
elif prefix and not field.key:
full_key = prefix
else:
full_key = field.key
match field.type:
case FormFieldType.GROUP:
group_box = QGroupBox(field.label)
group_layout = QFormLayout(group_box)
_build_ui_recursively(
schema=field.children,
parent_layout=group_layout,
widget_registry=widget_registry,
prefix=f"{full_key}",
)
parent_layout.addRow(group_box)
case _:
full_key, _ = build_and_add_widget(
field,
parent_layout,
widget_registry=widget_registry,
prefix=prefix,
)
keys.append(full_key)
return keys
def _add_tooltip(
widget: QWidget,
tooltip: str,
) -> QHBoxLayout:
field_layout = QHBoxLayout()
field_layout.setContentsMargins(0, 0, 0, 0)
field_layout.setSpacing(5)
info_btn = QPushButton("ℹ️")
info_btn.setFixedSize(28, 27)
info_btn.setFlat(True)
info_btn.setCursor(Qt.CursorShape.PointingHandCursor)
info_btn.setToolTip(tooltip)
field_layout.addWidget(widget)
field_layout.addWidget(info_btn)
return field_layout
def reset_form(
widget_registry: WidgetRegistry,
) -> None:
for registry_entry in widget_registry.values(): # type: ignore
widget = registry_entry["widget"]
form_field = registry_entry["form_field"]
# TODO check if this behaviour is correct in other contexts, deactivated because of
# company search widget
# if form_field.readonly:
# continue
if isinstance(widget, QLineEdit):
widget.clear()
if form_field.fill_value:
widget.setText(form_field.fill_value)
elif isinstance(widget, QPlainTextEdit):
widget.clear()
elif isinstance(widget, QDateEdit):
if form_field.fill_value:
set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.setDate(set_date)
else:
widget.setDate(QDate.currentDate())
elif isinstance(widget, FlexibleDateInput):
widget.reset()
if form_field.fill_value:
set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_date(set_date)
elif isinstance(widget, FlexibleDateTimeInput):
widget.reset()
if form_field.fill_value:
set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm")
if not set_datetime.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_datetime(set_datetime)
elif isinstance(widget, QComboBox):
if form_field.fill_value:
widget.setCurrentText(form_field.fill_value)
elif form_field.type is FormFieldType.DROPDOWN:
widget.setCurrentIndex(0)
else:
widget.setCurrentIndex(-1)
elif isinstance(
widget,
(
DynamicListWidget,
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
CustomFormWidget,
),
):
# custom widget classes manage their widgets on their own
widget.reset()
widget.setStyleSheet("")
@dc.dataclass(slots=True)
class SubForm:
entry_box: QWidget
prefix_parent: str
index: int
prefix: str = ""
registry: WidgetRegistry = dc.field(default_factory=dict)
full_keys: list[str] = dc.field(default_factory=list)
def __post_init__(self) -> None:
self.prefix = f"{self.prefix_parent}-[{self.index}]"
def update_index(self, new_idx: int) -> None:
self.index = new_idx
self.prefix = f"{self.prefix_parent}-[{self.index}]"
def change_sub_form_widget_registry(
widget_registry: WidgetRegistry,
sub_form: SubForm,
new_idx: int,
) -> None:
if sub_form.index == new_idx:
return
old_key_part = sub_form.prefix
new_key_part = f"{sub_form.prefix_parent}-[{new_idx}]"
for key in tuple(widget_registry.keys()):
splitted = key.split(COLUMN_SEP)
key_part, rest = splitted[0], splitted[1:]
if key_part == old_key_part:
old_key = COLUMN_SEP.join([old_key_part] + rest)
new_key = COLUMN_SEP.join([new_key_part] + rest)
widget_registry[new_key] = widget_registry[old_key]
del widget_registry[key]
sub_form.update_index(new_idx)
def update_sub_forms(
widget_registry: WidgetRegistry,
sub_forms: Sequence[SubForm],
base_label: str = "",
):
total_num_sub_forms = len(sub_forms)
for index, sub_form in enumerate(sub_forms, start=1):
if isinstance(sub_form.entry_box, QGroupBox) and base_label:
sub_form.entry_box.setTitle(f"{base_label} {index}")
change_sub_form_widget_registry(
widget_registry,
sub_form,
index,
)
for key in tuple(widget_registry.keys()):
matches = DYNAMIC_LIST_KEY_PATTERN.search(key)
if not matches:
continue
counter_sub_form = int(matches.group(1))
if counter_sub_form > total_num_sub_forms:
del widget_registry[key]
def get_form_data(
widget_registry: WidgetRegistry,
filter_keys: Container[str] = tuple(),
) -> dict[str, Any]:
raw_data: dict[str, Any] = {}
ignored_keys: list[str] = []
for key, registry_entry in widget_registry.items():
value: Any | None = None
if filter_keys:
data_key = key.split(COLUMN_SEP)[-1]
if data_key not in filter_keys:
continue
widget = registry_entry["widget"]
form_field = registry_entry["form_field"]
if form_field.ignore_get_data:
ignored_keys.append(key)
continue
if form_field.type is FormFieldType.TEXT_DATE:
value = get_widget_value(widget, "date")
elif form_field.type is FormFieldType.TEXT_DATETIME:
value = get_widget_value(widget, "datetime")
else:
value = get_widget_value(widget)
if isinstance(value, dict):
raw_data.update(value)
else:
raw_data[key] = value
logger_get_data.debug("[base:get_form_data]>>>>> RAW DATA:\n%s", pformat(raw_data))
return raw_data
def get_widget_value(
widget: QWidget,
text_type: Literal["default", "date", "datetime"] = "default",
) -> Any:
value: Any | None = None
if isinstance(widget, QLineEdit):
data = widget.text()
if data != "":
value = data
if text_type == "date":
value = datetime.datetime.strptime(value, DATE_FMT).date()
elif text_type == "datetime":
value = datetime.datetime.strptime(value, DATETIME_FMT)
elif isinstance(widget, QPlainTextEdit):
data = widget.toPlainText()
if data != "":
value = data
elif isinstance(widget, QDateEdit):
qt_date = widget.date()
value = qt_date.toPython()
elif isinstance(widget, FlexibleDateInput):
value = widget.get_pydate()
elif isinstance(widget, FlexibleDateTimeInput):
value = widget.get_pydatetime()
elif isinstance(widget, QComboBox):
value = widget.currentData()
elif isinstance(
widget,
(
DynamicListWidget,
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
CustomFormWidget,
),
):
# this is a special data structure with some assumptions of the widget's internals
# DynamicListWidget: this is a list -> each dynamic list contains a list
# of such dictionaries (handled below)
value = widget.get_form_data()
return value
def set_widget_value(
widget: QWidget,
value: Any,
) -> None:
if value is None:
return
elif value is True:
value = "ja"
elif value is False:
value = "nein"
if isinstance(widget, QLineEdit):
if isinstance(value, datetime.datetime):
value = value.astimezone()
value = value.strftime(DATETIME_FMT)
elif isinstance(value, datetime.date):
value = value.strftime(DATE_FMT)
widget.setText(str(value))
elif isinstance(widget, QPlainTextEdit):
widget.setPlainText(str(value))
elif isinstance(widget, QDateEdit):
assert isinstance(value, datetime.date)
set_date = QDate(value.year, value.month, value.day)
if not set_date.isValid():
raise ValueError(f"Could not parse date field value >{value}<")
widget.setDate(set_date)
elif isinstance(widget, FlexibleDateInput):
assert isinstance(value, datetime.datetime)
try:
widget.set_pydate(value, raise_error=True)
except ValueError:
raise ValueError(f"Could not parse date field value >{value}<")
elif isinstance(widget, FlexibleDateTimeInput):
assert isinstance(value, datetime.datetime)
try:
widget.set_pydatetime(value, raise_error=True)
except ValueError:
raise ValueError(f"Could not parse datetime field value >{value}<")
elif isinstance(widget, QComboBox):
index = widget.findData(value)
if index >= 0:
widget.setCurrentIndex(index)
elif isinstance(widget, DynamicListWidget):
assert isinstance(value, list)
widget.set_form_data(value)
elif isinstance(
widget,
(
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
Grunderfassung_SuchWidget,
),
):
assert isinstance(value, dict)
widget.set_form_data(value)
def set_form_data(
widget_registry: WidgetRegistry,
data: dict[str, Any],
filter_keys: Container[str] = tuple(),
) -> None:
for key, entry in widget_registry.items():
widget = entry["widget"]
if filter_keys:
data_key = key.split(COLUMN_SEP)[-1]
if data_key not in filter_keys:
continue
if isinstance(
widget,
(
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
Grunderfassung_SuchWidget,
),
):
value = data
else:
if key not in 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)
set_widget_value(widget, value)
def validate_form_data(
widget_registry: WidgetRegistry,
) -> list[str]:
errors: list[str] = []
for key, registry_entry in widget_registry.items():
error_post: bool = False
custom_formatting: bool = False
widget = registry_entry["widget"]
form_field = registry_entry["form_field"]
if not form_field.readonly:
widget.setStyleSheet("")
if not form_field.required:
continue
dynamic_list_num: str = ""
if (
form_field.parent is not None
and form_field.parent.type is FormFieldType.DYNAMIC_LIST
):
# get also number of group for enhanced error messages
matches = DYNAMIC_LIST_KEY_PATTERN.search(key)
if matches:
dynamic_list_num = matches.group(1)
if isinstance(widget, (QLineEdit, QDateEdit)):
if widget.text().strip():
continue
error_post = True
elif isinstance(widget, QPlainTextEdit):
if widget.toPlainText().strip():
continue
error_post = True
elif isinstance(widget, QComboBox):
if widget.currentData() is not None:
continue
error_post = True
elif isinstance(widget, (FlexibleDateInput, FlexibleDateTimeInput)):
valid = widget.validate()
error_post = not valid
custom_formatting = True
elif isinstance(
widget,
(
DynamicListWidget,
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
CustomFormWidget,
),
):
errors_widget = widget.validate()
if not errors_widget:
continue
errors.extend(errors_widget)
if not error_post:
continue
error = form_field.label.replace("*", "").replace(":", "")
if form_field.parent is not None:
parent_label = form_field.parent.label.replace("*", "").replace(":", "")
if dynamic_list_num:
error = f"{parent_label} → {f'{parent_label} {dynamic_list_num}'} → {error}"
else:
error = f"{parent_label} → {error}"
error = error.replace("&&", "&")
errors.append(error)
# optical feedback to highlight erroneous cells
if not custom_formatting:
widget.setStyleSheet("""
border: 1px solid #ef4444;
background-color: #ffe9e9;
padding: 4px;
border-radius: 4px;
""")
return errors
def validate_child_modules(
modules: Iterable[Module],
) -> list[str]:
errors: list[str] = []
for mod in modules:
errors.extend(mod.validate())
return errors
def lock_form(
widget_registry: WidgetRegistry,
) -> None:
for registry_entry in widget_registry.values(): # type: ignore
widget = registry_entry["widget"]
form_field = registry_entry["form_field"]
if form_field.readonly:
continue
if isinstance(
widget,
(
QLineEdit,
QPlainTextEdit,
QDateEdit,
FlexibleDateInput,
FlexibleDateTimeInput,
),
):
widget.setReadOnly(True)
elif isinstance(widget, QComboBox):
widget.setEnabled(False)
elif isinstance(
widget,
(
DynamicListWidget,
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
CustomFormWidget,
),
):
widget.lock()
widget.setProperty("styleClass", "stempel")
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def unlock_form(
widget_registry: WidgetRegistry,
) -> None:
for registry_entry in widget_registry.values(): # type: ignore
widget = registry_entry["widget"]
form_field = registry_entry["form_field"]
if form_field.readonly:
continue
if isinstance(
widget,
(
QLineEdit,
QPlainTextEdit,
QDateEdit,
FlexibleDateInput,
FlexibleDateTimeInput,
),
):
widget.setReadOnly(False)
elif isinstance(widget, QComboBox):
widget.setEnabled(True)
elif isinstance(
widget,
(
DynamicListWidget,
DynamicDropdownWidgetNumeric,
DynamicDropdownWidgetOption,
CustomFormWidget,
),
):
widget.unlock()
widget.setProperty("styleClass", "")
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
class Grunderfassung_SuchWidget(CustomFormWidget):
def __init__(
self,
form_fields: Sequence[FormField],
label: str = "Suche",
prefix: str = "",
):
super().__init__(
form_fields=form_fields,
label=label,
prefix=prefix,
)
self.form_fields = form_fields
self.label = label
self.prefix = prefix
self.widget_registry: WidgetRegistry = {}
self.export_data: dict[str, Any] = {}
self.PROPERTY_MA_ID = "user_ma_id"
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
self.form_layout = QFormLayout()
self.form_layout.setSpacing(10)
main_layout.addLayout(self.form_layout)
_build_ui_recursively(
self.form_fields,
self.form_layout,
self.widget_registry,
self.prefix,
)
self.COMPANY_SEARCH_INPUT_KEY = "un_suche"
lookup = search_widgets_by_key(self.widget_registry, self.COMPANY_SEARCH_INPUT_KEY)
assert len(lookup) == 1
self.company_search_input = cast(QComboBox, lookup[0]["widget"])
self.company_search_input.currentIndexChanged.connect(self._selected_company)
self.PERSON_SEARCH_INPUT_KEY = "person_suche"
lookup = search_widgets_by_key(self.widget_registry, self.PERSON_SEARCH_INPUT_KEY)
assert len(lookup) == 1
self.person_search_input = cast(QComboBox, lookup[0]["widget"])
self.person_search_input.currentIndexChanged.connect(self._selected_person)
self.CHANNEL_AWARENESS_KEY = "kanal_aufmerksamkeit"
lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_AWARENESS_KEY)
assert len(lookup) == 1
self.channel_awareness = cast(QComboBox, lookup[0]["widget"])
self.DATA_EXPORT_FIELDS = (
self.COMPANY_SEARCH_INPUT_KEY,
self.PERSON_SEARCH_INPUT_KEY,
self.CHANNEL_AWARENESS_KEY,
)
self.company_widgets: dict[str, QWidget] = {}
self.person_widgets: dict[str, QWidget] = {}
self.properties_company: tuple[str, ...] = (
"ma_unternehmensname",
"ma_strasse",
"ma_hausnummer",
"ma_plz",
"ma_ort",
)
self.properties_person: tuple[str, ...] = (
"an_titel",
"an_anrede",
"an_nachname",
"an_vorname",
"an_festnetz",
"an_mobil",
"an_mail",
"an_position",
)
for entry in self.widget_registry.values():
field = entry["form_field"]
widget = entry["widget"]
if field.info in self.properties_company:
self.company_widgets[field.info] = widget
elif field.info in self.properties_person:
self.person_widgets[field.info] = widget
if DEBUG_SEARCH_WIDGET:
button = QPushButton("Print Registry")
button.clicked.connect(self.print_registry)
main_layout.addWidget(button)
button = QPushButton("Reset form")
button.clicked.connect(self.reset)
main_layout.addWidget(button)
button = QPushButton("Get form data")
button.clicked.connect(self.get_form_data)
main_layout.addWidget(button)
button = QPushButton("Set form data")
button.clicked.connect(self.set_form_data)
main_layout.addWidget(button)
button = QPushButton("Validate")
button.clicked.connect(self.validate)
main_layout.addWidget(button)
self.update_company_data()
def print_registry(self) -> None:
pprint_registry(self.widget_registry)
def fill_out_company(
self,
data: types.CompanyInfo,
) -> None:
for key, widget in self.company_widgets.items():
if key not in data:
raise KeyError(
(
f"Key: {key} not found in company info. Add the key "
f"to the info property in the form field definition"
)
)
set_widget_value(widget, data[key])
def fill_out_person(
self,
data: types.ContactPersonInfo,
) -> None:
for key, widget in self.person_widgets.items():
if key not in data:
raise KeyError(
(
f"Key: {key} not found in company info. Add the key "
f"to the info property in the form field definition"
)
)
set_widget_value(widget, data[key])
def _clear_company_fields(self) -> None:
self.person_search_input.clear()
self.channel_awareness.setCurrentIndex(0)
for widget in self.company_widgets.values():
widget = cast(QLineEdit, widget)
widget.clear()
def _clear_person_fields(self) -> None:
for widget in self.person_widgets.values():
widget = cast(QLineEdit, widget)
widget.clear()
def update_company_data(self) -> None:
self.company_search_input.clear()
self.company_search_input.addItem(DROPDOWN_DEFAULT, None)
search_choices = backend.initrec_comp_search_choices()
for item, db_index in search_choices:
self.company_search_input.addItem(item, db_index)
self.company_search_input.setCurrentIndex(-1)
def update_person_data(
self,
ma_id: int | None,
) -> None:
self.person_search_input.clear()
self.person_search_input.addItem(DROPDOWN_DEFAULT, None)
search_choices = backend.initrec_comp_contact_person_search_choices(ma_id, True)
for item, db_index in search_choices:
self.person_search_input.addItem(item, db_index)
self.person_search_input.setCurrentIndex(0)
def _selected_company(
self,
index: int,
) -> None:
ma_id = self.company_search_input.itemData(index)
if ma_id is None or index == (-1):
self._clear_company_fields()
return
data = backend.initrec_comp_search_get_info(
ma_id=ma_id,
)
self.fill_out_company(data)
self.update_person_data(ma_id)
def _selected_person(
self,
index: int,
) -> None:
an_id = self.person_search_input.itemData(index)
if an_id is None or index == (-1):
self._clear_person_fields()
return
data = backend.initrec_comp_contact_person_search_get_info(
an_id=an_id,
)
self.fill_out_person(data)
@override
def reset(self) -> None:
reset_form(self.widget_registry)
self._clear_company_fields()
self._clear_person_fields()
@override
def validate(self) -> list[str]:
errors = validate_form_data(self.widget_registry)
return errors
@override
def get_form_data(self) -> dict[str, Any]:
form_data = get_form_data(self.widget_registry, filter_keys=self.DATA_EXPORT_FIELDS)
logger_get_data.debug("Form data CustomWidget:\n%s", pformat(form_data))
return form_data
@override
def set_form_data(
self,
data: dict[str, Any],
) -> None:
if DEBUG_SEARCH_WIDGET:
if not self.export_data:
raise RuntimeError("No data. Get form data first.")
data = self.export_data
set_form_data(self.widget_registry, data, filter_keys=self.DATA_EXPORT_FIELDS)
@override
def lock(
self,
) -> None:
lock_form(self.widget_registry)
@override
def unlock(
self,
) -> None:
unlock_form(self.widget_registry)
def enhanced_label(
base_label: str,
add_text: str,
add_colon: bool = False,
) -> str:
label = base_label.strip().replace("*", "").replace(":", "")
if add_text:
label = label + f" {add_text}"
if add_colon and not label.endswith(":"):
label += ":"
return label
class WidgetRegistryEntry(TypedDict):
widget: QWidget
form_field: FormField
WidgetRegistry: TypeAlias = dict[str, WidgetRegistryEntry]
def search_widgets_by_key(
widget_registry: WidgetRegistry,
key_part: str,
) -> list[WidgetRegistryEntry]:
"""
needed for custom logic of auto-built forms,
search for specific keys and obtain the widget to assign
special logic or callbacks to them
"""
hits: list[WidgetRegistryEntry] = []
for key, entry in widget_registry.items():
if key_part in key:
hits.append(entry)
return hits
class InitRecForm(QWidget):
"""a widget, which is managed by a code-defined field definition collection"""
update_triggered = Signal() # formular saved (data changed for front page)
def __init__(
self,
state: Page_InitRec_Form_State,
) -> None:
super().__init__()
self.STATE = state
self.SESSION_USER_KEY: Final[str] = "Metadaten_nutzer"
self.cfg = state.cfg
self.setStyleSheet("""
QGroupBox {
font-size: 16px;
font-weight: bold;
border: 1px solid #cbd5e1; /* Heller, moderner Rahmen */
border-radius: 8px; /* Abgerundete Ecken */
margin-top: 15px; /* Platz für die Überschrift schaffen */
padding-top: 15px; /* Abstand zwischen Rahmen und erstem Feld */
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left; /* Überschrift oben links */
padding: 0 5px; /* Etwas Luft links und rechts vom Text */
color: #334155; /* Dunkelgraue Schrift */
}
QComboBox {
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 5px;
}
QComboBox:disabled {
background-color: #f1f5f9; /* Helles System-Grau */
color: #333D4B; /* Gut lesbare, aber gedeckte Schrift */
border: 1px dashed #cbd5e1; /* Gestrichelter Rand wie beim Datum */
}
QComboBox::drop-down:disabled {
border: none;
image: none;
}
""")
# --- special funtionality ---
self.no_scroll_filter = NoScrollFilter(self)
# --- LAYOUT ---
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
if DEBUG:
separator1 = QFrame()
separator1.setFrameShape(QFrame.Shape.HLine)
separator1.setFrameShadow(QFrame.Shadow.Sunken)
self.main_layout.addWidget(separator1)
self.registry_button = QPushButton("Ausgabe Widget Registry")
self.registry_button.clicked.connect(self._print_registry)
self.registry_button.setFixedHeight(35)
self.main_layout.addWidget(self.registry_button)
# self.test_button = QPushButton("Initialisiere Laden")
# self.test_button.clicked.connect(self.load_from_ids)
# self.test_button.setFixedHeight(35)
# self.test_button.setSizePolicy(
# QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
# )
# self.main_layout.addWidget(self.test_button)
button_get = QPushButton("GET DATA")
button_get.setFixedHeight(35)
button_get.clicked.connect(self._get_data)
self.main_layout.addWidget(button_get)
button_set = QPushButton("SET DATA")
button_set.setFixedHeight(35)
button_set.clicked.connect(self._set_data)
self.main_layout.addWidget(button_set)
id_field_layout = QHBoxLayout()
id_field_layout.setContentsMargins(0, 0, 0, 0)
id_field_layout.setSpacing(5)
id_field_label = QLabel("ID Datenbank:")
self.id_field_input = QLineEdit()
id_field_layout.addWidget(id_field_label)
id_field_layout.addWidget(self.id_field_input)
self.main_layout.addLayout(id_field_layout)
button_db_index = QPushButton("Setze DB Index")
button_db_index.setFixedHeight(35)
button_db_index.clicked.connect(self._set_db_index)
self.main_layout.addWidget(button_db_index)
separator2 = QFrame()
separator2.setFrameShape(QFrame.Shape.HLine)
separator2.setFrameShadow(QFrame.Shadow.Sunken)
self.main_layout.addWidget(separator2)
self.main_layout.addSpacing(5)
self.main_layout.addSpacing(10)
# buttons (save and reset)
self.add_buttons = self.cfg.add_buttons
self.edit_buttons: EditButtons | None = None
if self.add_buttons:
self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True)
modify_btn = self.edit_buttons.modify_button
assert modify_btn
self.modify_btn = modify_btn
reset_btn = self.edit_buttons.reset_btn
assert reset_btn
self.reset_btn = reset_btn
self.save_btn = self.edit_buttons.save_btn
self.delete_btn = self.edit_buttons.delete_btn
self.modify_btn.modify_activated.connect(self.unlock)
self.modify_btn.modify_deactivated.connect(self.lock)
# self.save_btn.clicked.connect(self.save_data)
self.reset_btn.clicked.connect(self.reset_form)
# self.delete_btn.clicked.connect(self._delete_data)
self.main_layout.addWidget(self.edit_buttons)
self.top_level_form_layout = QFormLayout()
self.main_layout.addLayout(self.top_level_form_layout)
self.top_level_form_layout.setSpacing(10)
self.form_fields = self.cfg.form_fields
self.widget_registry: WidgetRegistry = {}
_build_ui_recursively(
self.form_fields,
self.top_level_form_layout,
self.widget_registry,
)
self.debug_form_data: dict[str, Any] = {}
logger_initrecform.debug(
"[InitRecForm] Initialised Auto Form Widget. Registry:%s",
pformat_registry(self.widget_registry),
)
def _print_registry(self) -> None:
pprint_registry(self.widget_registry)
def _set_db_index(self) -> None:
try:
index = int(self.id_field_input.text())
except ValueError:
index = None
self.STATE.ids.un_id = index
self.STATE.ids.pers_id = index
logger_gui.debug("[InitRecForm] Set index to %s, new state: %s", index, self.STATE)
# def _disable_save(self) -> None:
# assert self.save_btn is not None
# assert self.edit_buttons is not None
# self.save_btn.setEnabled(False)
# self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled)
# def _enable_save(
# self,
# timeout: int = 3000,
# ) -> None:
# assert self.save_btn is not None
# assert self.edit_buttons is not None
# QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True))
# QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S"))
# self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled)
def relevant_ids_set(self) -> bool:
return self.STATE.ids.valid(self.STATE.ent_type)
# def _activate_delete(self) -> None:
# if self.STATE.ids.valid():
# self.delete_btn.setEnabled(True)
# else:
# self.delete_btn.setEnabled(False)
# def _delete_data(self) -> None:
# assert self.STATE.ids.valid(), "deletion initialised despite IDs are not properly set"
# confirm = get_user_confirmation(
# self,
# "Löschen bestätigen",
# "Soll der Eintrag wirklich gelöscht werden?",
# )
# if not confirm:
# return
# self.STATE.geloescht = True
# self.save_data()
# self.update_triggered.emit()
# !! must be part of the managing page
# def load_from_ids(
# self,
# ids: NewEntityIds,
# ) -> None:
# if DEBUG_NO_DATABASE:
# return
# logger_initrecform.info("[InitRecForm] Load data method...")
# logger_initrecform.debug("[InitRecForm] Lookup ID: %s", ids)
# self.reset_form()
# if ids.empty(self.STATE.recording_type):
# logger_initrecform.debug("[InitRecForm] Loading: Lookup ID NULL. Do nothing!")
# return
# logger_initrecform.debug("Load from DB:")
# from_db = self.cfg.from_db(ids)
# # TODO add path to avoid unnecessary loading (where the same ID is loaded
# # twice in a row) -- use an internal marker (not state-related)
# # logger_auto_form.debug(
# # "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data)
# # )
# logger_initrecform.debug(
# "[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...",
# pformat(from_db.db_data),
# )
# model = self.cfg.pydantic_model(**from_db.db_data)
# logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.")
# logger_initrecform.debug("[InitRecForm] Convert to GUI structure...")
# form_data = model.to_gui()
# logger_initrecform.debug("[InitRecForm] Set form data...")
# logger_initrecform.debug("[InitRecForm] Form data:\n%s", pformat(form_data))
# self._set_data(form_data)
# # self.STATE.ids.set_id(id_, self.STATE.ent_type)
# self.STATE.ids.update(from_db.ids)
# self.STATE.form_data = self._get_data()
# logger_initrecform.debug(
# "[InitRecForm] Model dump vs. get form data:\n%s\n\n%s",
# pformat(model.model_dump(mode="python")),
# pformat(self.STATE.form_data),
# )
def _load_state_form_data(self) -> None:
logger_initrecform.info("[InitRecForm] Load data method...")
logger_initrecform.debug(
"[InitRecForm] Loaded data dict:\n%s Passing to Pydantic...",
pformat(self.STATE.form_data),
)
# TODO error catching?
model = self.cfg.pydantic_model(**self.STATE.form_data)
logger_initrecform.debug("[InitRecForm] Loaded to Pydantic.")
logger_initrecform.debug("[InitRecForm] Convert to GUI structure...")
form_data_gui = model.to_gui()
self._set_data(form_data_gui)
def reset_form(self) -> None:
reset_form(self.widget_registry)
self.STATE.ids.update(EntityIds())
self.STATE.form_data.clear()
self.STATE.geloescht = False
def lock(self) -> None:
self.STATE.locked = True
self._sync_state_to_GUI("locking")
def unlock(self) -> None:
self.STATE.locked = False
self._sync_state_to_GUI("locking")
def _get_data(self) -> dict[str, Any]:
form_data = get_form_data(self.widget_registry)
logger_initrecform.debug("[InitRecForm] Call get form data:")
logger_initrecform.debug("[InitRecForm] Form Data:\n%s", pformat(form_data))
if DEBUG_GET_SET:
self.debug_form_data = form_data
return form_data
def _set_data(
self,
data: dict[str, Any],
) -> None:
logger_initrecform.debug("[InitRecForm] Call set form data...")
if DEBUG_GET_SET:
data = self.debug_form_data
set_form_data(self.widget_registry, data)
def _sync_state_to_GUI(
self,
section: Literal["form", "locking"] | None,
) -> None:
# TODO this should be initialised by states analogous to consulting sessions
# !! loading from IDs is only performed on top-level modules aka pages
if section is None or section == "form":
if not self.STATE.geloescht:
self._load_state_form_data()
else:
self.reset_form()
if section is None or section == "locking":
if self.STATE.locked:
if self.edit_buttons is not None:
self.edit_buttons.set_modify_state(False, set_delete_btn=True)
lock_form(self.widget_registry)
else:
if self.edit_buttons is not None:
self.edit_buttons.set_modify_state(True, set_delete_btn=False)
unlock_form(self.widget_registry)
# self._activate_delete()
def _sync_GUI_to_state(self) -> None:
form_data = self._get_data()
# TODO change usage of user ID (needs changes in model and backend)
if self.SESSION_USER_KEY in form_data:
form_data[self.SESSION_USER_KEY] = self.STATE.session.user_name
for form_key, id_key in self.STATE.cfg.id_mapping.items():
self.STATE.ids.set_by_name(id_key, form_data[form_key])
self.STATE.form_data = form_data
def validate(self) -> list[str]:
return validate_form_data(self.widget_registry)
def load_state(
self,
new_state: Page_InitRec_Form_State,
) -> None:
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI(None)
def get_state(self) -> Page_InitRec_Form_State:
self._sync_GUI_to_state()
return self.STATE
# def _load_from_pydantic(
# self,
# data: InitRec,
# ) -> None:
# # ent_type = self.STATE.recording_type
# # self.STATE.ids.set_id(data.ids.get_id(ent_type), ent_type)
# self.STATE.ids.update(data.ids)
# # self.STATE.rec_id = data.rec_id
# self.STATE.form_data.clear()
# self.STATE.geloescht = True if data.geloescht else False
# self._sync_state_to_GUI(None) # TODO change to correct loading
# !! must be part of the managing page
def save_data(self) -> None:
pass
# def save_data(self) -> None:
# if self.STATE.ids.empty() and self.STATE.geloescht:
# logger_initrecform.debug(
# "[InitRecForm] This was never saved in the database "
# "and is marked for deletion - ignore."
# )
# return
# elif not self.STATE.ids.empty() and self.STATE.geloescht:
# logger_initrecform.debug(
# "[InitRecForm] This was saved in the database "
# "and is marked for deletion - fast path."
# )
# deleted_datetime = datetime.datetime.now(datetime.UTC)
# assert self.STATE.form_data, "cannot delete entry with uninitialised form data"
# to_db = InitRec(
# ids=self.STATE.ids,
# geloescht=deleted_datetime,
# db_data={},
# )
# returned = self.cfg.to_db(to_db)
# self._load_from_pydantic(returned)
# msg_box = get_message_box(
# QMessageBox.Icon.Information,
# "Löschen erfolgreich",
# "Der Eintrag wurde erfolgreich gelöscht",
# )
# msg_box.exec()
# return
# self._disable_save()
# errors = self.validate()
# if errors:
# logger_initrecform.error(
# "[InitRecForm] Error during GUI validation phase:\n%s", pformat(errors)
# )
# GUI_pydantic_validation_error_handling(errors)
# self._enable_save()
# return
# logger_initrecform.info("[InitRecForm] Success! All data is valid")
# logger_initrecform.info("[InitRecForm] Get form data call...")
# state = self.get_state()
# form_data = state.form_data
# assert form_data, "form data not set"
# logger_initrecform.debug("[InitRecForm] Get form data\n%s", pformat(form_data))
# try:
# logger_initrecform.debug("[InitRecForm] Call Pydantic...")
# validated_data = self.cfg.pydantic_model(**form_data)
# except ValidationError as e:
# logger_initrecform.error(
# "[InitRecForm] Error during Pydantic validation phase:\n%s",
# pformat(e.errors()),
# )
# pydantic_validation_error_handling(e)
# else:
# if DEBUG_NO_DATABASE:
# return
# # !! this code is only called if the 'try' block was successful
# # save data to database
# db_data = validated_data.to_db(exclude=self.cfg.ignored_keys)
# logger_initrecform.debug(
# "[InitRecForm] Form data with 'exlude' (must be saved in the database):\n%s",
# pformat(db_data),
# )
# deleted_datetime: datetime.datetime | None = None
# if self.STATE.geloescht:
# deleted_datetime = datetime.datetime.now(datetime.UTC)
# try:
# # TODO change to result pattern
# to_db = InitRec(
# ids=self.STATE.ids,
# # rec_id=self.STATE.rec_id,
# geloescht=deleted_datetime,
# db_data=db_data,
# )
# returned = self.cfg.to_db(to_db)
# self._load_from_pydantic(returned)
# self.update_triggered.emit()
# except Exception as err:
# msg_box = get_message_box(
# QMessageBox.Icon.Warning,
# "Speichern fehlgeschlagen",
# (
# "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind "
# "unten angefügt."
# ),
# detailed_text=str(err),
# )
# msg_box.exec()
# else:
# logger_initrecform.info("[InitRecForm] Data saved successfully")
# msg_box = get_message_box(
# QMessageBox.Icon.Information,
# "Speichern erfolgreich",
# "Die Daten wurden erfolgreich gespeichert",
# )
# msg_box.exec()
# finally:
# # always re-enable save, even if error occurred
# self._enable_save()
class FlexibleDateInput(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._read_only: bool = False
self._required: bool = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# simple text field allows for easy editing and deletion
self.line_edit = QLineEdit()
self.line_edit.setPlaceholderText("TT.MM.JJJJ")
self.line_edit.setInputMask("99.99.9999;_") # force date structure
self.button = QPushButton("📅")
self.button.setFixedWidth(30)
self.line_edit.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
layout.addWidget(self.line_edit)
layout.addWidget(self.button)
self.calendar = QCalendarWidget()
self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window
self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday)
self.calendar.setGridVisible(True)
self.calendar.setStyleSheet("""
QCalendarWidget QAbstractItemView {
selection-background-color: #2980b9;
selection-color: white;
}
""")
self.button.clicked.connect(self.show_calendar)
self.calendar.activated.connect(self.set_date)
# signal to validate
self.line_edit.editingFinished.connect(self._validate_input)
self.line_edit.installEventFilter(self)
self.setReadOnly(False)
def eventFilter(
self,
watched: QObject,
event: QEvent,
):
# press enter: direct validation
if watched == self.line_edit:
if event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore
self._validate_input()
return True # mark event as processed
elif event.type() == QEvent.Type.FocusOut:
self._validate_input()
# no return to not block focus change
return super().eventFilter(watched, event)
def show_calendar(self) -> None:
if self._read_only:
return
pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft())
self.calendar.move(pos)
self.calendar.show()
def isRequired(self) -> bool:
return self._required
def setRequired(
self,
required: bool,
) -> None:
self._required = required
def isReadOnly(self) -> bool:
return self._read_only
def setReadOnly(
self,
read_only: bool,
style_class_readonly: str = DEFAULT_READONLY_STYLECLASS,
):
self._read_only = read_only
self.line_edit.setReadOnly(read_only)
self.button.setEnabled(not read_only)
current_text = self.line_edit.text()
if read_only:
self.line_edit.setInputMask("")
self.line_edit.setPlaceholderText("kein Datum eingetragen")
pure = (
current_text.replace(".", "")
.replace(":", "")
.replace(" ", "")
.replace("_", "")
.strip()
)
cleaned_date = current_text if pure else ""
self.line_edit.setText(cleaned_date)
self.line_edit.setProperty("styleClass", style_class_readonly)
else:
self.line_edit.setInputMask("99.99.9999;_")
self.line_edit.setPlaceholderText("TT.MM.JJJJ")
self.line_edit.setProperty("styleClass", "")
self.line_edit.style().unpolish(self.line_edit)
self.line_edit.style().polish(self.line_edit)
self.line_edit.update()
def _validate_input(
self,
) -> None:
logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation")
if self._read_only:
return
focused_widget = QApplication.focusWidget()
if focused_widget == self.button or focused_widget == self.calendar:
return
text = self.line_edit.text()
# field empty
pure_text = text.replace(".", "").replace("_", "").strip()
if not self._required and not pure_text:
self.line_edit.setStyleSheet("")
return
# if user wrote input
date = QDate.fromString(text, "dd.MM.yyyy")
if not date.isValid():
self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;")
self.line_edit.selectAll()
# optional: keep focus in field to directly correct entry
# self.line_edit.setFocus()
else:
self.line_edit.setStyleSheet("")
def validate(
self,
) -> bool:
logger_custom_fields.debug("[CustomField - FlexDate]: Call validate...")
self._validate_input()
date = self.get_date()
if date and date.isValid():
logger_custom_fields.debug("[CustomField - FlexDate]: returning valid")
return True
logger_custom_fields.debug("[CustomField - FlexDate]: returning invalid")
return False
def set_date(
self,
date: QDate,
) -> None:
if self._read_only:
return
if date and date.isValid():
self.line_edit.setText(date.toString("dd.MM.yyyy"))
else:
self.line_edit.clear()
self.line_edit.setText(date.toString("dd.MM.yyyy"))
self.calendar.hide()
def set_pydate(
self,
date: datetime.datetime,
raise_error: bool = False,
) -> None:
date_to_set = QDate(date.year, date.month, date.day)
if raise_error and not date_to_set.isValid():
raise ValueError("Could not parse date object correctly")
self.set_date(date_to_set)
def get_date(self) -> QDate:
# valid or invalid QDate
return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy")
def get_pydate(self) -> datetime.date | None:
# valid or invalid QDate
date = self.get_date()
if not date.isValid():
return None
py_date = cast(datetime.date, date.toPython())
return py_date
def reset(self) -> None:
self.calendar.hide()
self.line_edit.clear()
class FlexibleDateTimeInput(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._read_only: bool = False
self._required: bool = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Simple text field allows for easy editing and deletion (with Time)
self.line_edit = QLineEdit()
self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm")
self.line_edit.setInputMask("99.99.9999 99:99;_") # force datetime structure
self.button = QPushButton("📅")
self.button.setFixedWidth(30)
self.line_edit.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
layout.addWidget(self.line_edit)
layout.addWidget(self.button)
self.calendar = QCalendarWidget()
self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window
self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday)
self.calendar.setGridVisible(True)
self.calendar.setStyleSheet("""
QCalendarWidget QAbstractItemView {
selection-background-color: #2980b9;
selection-color: white;
}
""")
self.button.clicked.connect(self.show_calendar)
self.calendar.activated.connect(self.set_date_from_calendar)
# signal to validate
self.line_edit.editingFinished.connect(self._validate_input)
self.line_edit.installEventFilter(self)
self.setReadOnly(False)
def eventFilter(
self,
watched: QObject,
event: QEvent,
):
# press enter: direct validation
if watched == self.line_edit:
if event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore
self._validate_input()
return True # mark event as processed
elif event.type() == QEvent.Type.FocusOut:
self._validate_input()
# no return to not block focus change
return super().eventFilter(watched, event)
def show_calendar(self) -> None:
if self._read_only:
return
pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft())
self.calendar.move(pos)
self.calendar.show()
def isRequired(self) -> bool:
return self._required
def setRequired(
self,
required: bool,
) -> None:
self._required = required
def isReadOnly(self) -> bool:
return self._read_only
def setReadOnly(
self,
read_only: bool,
style_class_readonly: str = DEFAULT_READONLY_STYLECLASS,
):
self._read_only = read_only
self.line_edit.setReadOnly(read_only)
self.button.setEnabled(not read_only)
current_text = self.line_edit.text()
if read_only:
self.line_edit.setInputMask("")
self.line_edit.setPlaceholderText("kein Zeitstempel eingetragen")
pure = (
current_text.replace(".", "")
.replace(":", "")
.replace(" ", "")
.replace("_", "")
.strip()
)
cleaned_datetime = current_text if pure else ""
self.line_edit.setText(cleaned_datetime)
self.line_edit.setProperty("styleClass", style_class_readonly)
else:
self.line_edit.setInputMask("99.99.9999 99:99;_")
self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm")
self.line_edit.setProperty("styleClass", "")
self.line_edit.style().unpolish(self.line_edit)
self.line_edit.style().polish(self.line_edit)
self.line_edit.update()
def _validate_input(
self,
) -> None:
logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation")
if self._read_only:
return
focused_widget = QApplication.focusWidget()
if focused_widget == self.button or focused_widget == self.calendar:
return
text = self.line_edit.text()
# field empty (ignoring dots, colons, spaces, underscores)
pure_text = (
text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip()
)
if not self._required and not pure_text:
self.line_edit.setStyleSheet("")
return
# check logic for QDateTime
dt = QDateTime.fromString(text, "dd.MM.yyyy hh:mm")
if not dt.isValid():
self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;")
self.line_edit.selectAll()
else:
self.line_edit.setStyleSheet("")
def validate(
self,
) -> bool:
logger_custom_fields.debug("[CustomField - FlexDatetime]: Call validate...")
self._validate_input()
datetime = self.get_datetime()
if datetime and datetime.isValid():
logger_custom_fields.debug("[CustomField - FlexDatetime]: returning valid")
return True
logger_custom_fields.debug("[CustomField - FlexDatetime]: returning invalid")
return False
def set_date_from_calendar(
self,
date: QDate,
) -> None:
if self._read_only:
return
# save time user already provided it
current_text = self.line_edit.text()
time_part = "00:00"
# extract time portion (last 5 characters of "dd.MM.yyyy hh:mm")
if len(current_text) >= 16:
potential_time = current_text[11:16]
if "_" not in potential_time: # only if time was fully provided
time_part = potential_time
if date and date.isValid():
formatted_date = date.toString("dd.MM.yyyy")
self.line_edit.setText(f"{formatted_date} {time_part}")
self.line_edit.setStyleSheet("")
else:
self.line_edit.clear()
self.calendar.hide()
def set_datetime(
self,
dt: QDateTime,
) -> None:
if dt and dt.isValid():
self.line_edit.setText(dt.toString("dd.MM.yyyy hh:mm"))
self.line_edit.setStyleSheet("")
else:
self.line_edit.clear()
def set_pydatetime(
self,
dt: datetime.datetime,
raise_error: bool = False,
) -> None:
dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0)
if raise_error and not dt_to_set.isValid():
raise ValueError("Could not parse datetime object correctly")
self.set_datetime(dt_to_set)
def get_datetime(self) -> QDateTime:
# valid or invalid QDateTime
return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm")
def get_pydatetime(self) -> datetime.datetime | None:
"""returns current value of the field as UTC datetime
Returns
-------
datetime.datetime
UTC datetime object
"""
# valid or invalid datetime
dt = self.get_datetime()
if not dt.isValid():
return None
py_dt = cast(datetime.datetime, dt.toPython())
py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST)
return py_dt.astimezone(datetime.UTC)
def reset(self) -> None:
self.calendar.hide()
self.line_edit.clear()
class DynamicListWidget(QWidget):
"""
a widget, which can generate and manage an arbitrary number of sub forms.
"""
def __init__(
self,
form_fields: Sequence[FormField],
label: str = "Eintrag",
prefix: str = "",
add_empty_entry: bool = False,
):
super().__init__()
self.form_fields = form_fields
self.label = label
self.base_label = enhanced_label(label, add_text="")
self.prefix = prefix
self.widget_registry: WidgetRegistry = {}
# layout for group component
self.group_box = QGroupBox(label)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
# inner layout which contains sub forms and buttons
self.inner_layout = QVBoxLayout()
self.main_layout.addWidget(self.group_box)
self.group_box.setLayout(self.inner_layout)
# sub forms
self.sub_forms: list[SubForm] = []
# button to add more sub forms
self.add_btn = QPushButton("+ Hinzufügen")
self.add_btn.setProperty("styleClass", "outlineAddButton")
# self.add_btn.setStyleSheet(
# "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;"
# )
self.add_btn.clicked.connect(self.add_entry)
self.inner_layout.addWidget(self.add_btn)
# add empty sub form as initial value
self.add_empty_entry = add_empty_entry
if self.add_empty_entry:
self.add_entry()
def add_entry(self):
number_form = len(self.sub_forms) + 1
entry_box = QGroupBox(f"{self.label} {number_form}")
entry_layout = QHBoxLayout(entry_box)
sub_form = SubForm(entry_box, prefix_parent=self.prefix, index=number_form)
# widget_registry: WidgetRegistry = {}
form_layout = QFormLayout()
_build_ui_recursively(
schema=self.form_fields,
parent_layout=form_layout,
widget_registry=sub_form.registry,
# prefix=f"{self.prefix}-[{number_form}]",
prefix="",
)
self.widget_registry.update(sub_form.registry)
# sub_form.registry = widget_registry
del_btn = QPushButton("🗑️")
del_btn.setFixedSize(30, 30)
# Lambda with default parameter to delete exactly this(!) box
del_btn.clicked.connect(lambda checked=False, form=sub_form: self.remove_entry(form))
entry_layout.addLayout(form_layout)
entry_layout.addWidget(del_btn)
self.sub_forms.append(sub_form)
self.inner_layout.insertWidget(self.inner_layout.count() - 1, entry_box)
self.update_sub_forms()
def remove_entry(
self,
subform_to_remove: SubForm,
):
self.inner_layout.removeWidget(subform_to_remove.entry_box)
subform_to_remove.entry_box.deleteLater()
self.sub_forms.remove(subform_to_remove)
self.update_sub_forms()
def update_sub_forms(self):
update_sub_forms(
self.widget_registry,
sub_forms=self.sub_forms,
base_label=self.base_label,
)
def reset(self) -> None:
while self.sub_forms:
self.remove_entry(self.sub_forms[0])
if self.add_empty_entry:
self.add_entry()
def validate(self) -> list[str]:
errors = validate_form_data(self.widget_registry)
for form in self.sub_forms:
errors.extend(validate_form_data(form.registry))
return errors
def get_form_data(self) -> list[dict[str, Any]]:
logger_get_data.info("DynamicListWidget - Get Data")
form_data = [get_form_data(sub.registry) for sub in self.sub_forms]
logger_get_data.debug("DynamicListWidget - Form data:\n%s", pformat(form_data))
return form_data
def set_form_data(
self,
data: list[dict[str, Any]],
) -> None:
while self.sub_forms:
self.remove_entry(self.sub_forms[0])
# empty default row
if not data and self.add_empty_entry:
self.add_entry()
return
for sub_form_data in data:
self.add_entry()
current_sub_form = self.sub_forms[-1]
set_form_data(current_sub_form.registry, sub_form_data)
def lock(self) -> None:
lock_form(self.widget_registry)
self.add_btn.setEnabled(False)
def unlock(self) -> None:
unlock_form(self.widget_registry)
self.add_btn.setEnabled(True)
class DynamicDropdownWidgetNumeric(QWidget):
"""
A Widget, which can generate and manage an arbitrary number of sub forms with additional
information on a combobox selection (combobox).
"""
def __init__(
self,
form_fields: Sequence[FormField],
label_add_info: str = "Eintrag",
prefix: str = "",
):
super().__init__()
# form_fields = form_fields.children
if len(form_fields) == 0 or len(form_fields) > 1:
raise ValueError(
"Dynamic Dropdown Widget must have only one child, which is a dropdown widget"
)
self.combobox_field = form_fields[0]
assigned_form_fields = self.combobox_field.children
if len(assigned_form_fields) == 0 or len(assigned_form_fields) > 1:
raise ValueError(
(
"Dynamic Dropdown Widget's dropdown element must have only one "
"child, which is a single field definition"
)
)
self.type = type
self.assigned_form_field = assigned_form_fields[0]
self.label_add_info = label_add_info
self.prefix = prefix
self.widget_registry: WidgetRegistry = {}
# layout for group component
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.form_layout = QFormLayout()
self.main_layout.addLayout(self.form_layout)
self.full_keys = _build_ui_recursively(
[self.combobox_field],
self.form_layout,
self.widget_registry,
prefix=f"{self.prefix}",
)
dropdown_widget_entry = tuple(self.widget_registry.values())[0]
dropdown_widget = dropdown_widget_entry["widget"]
assert isinstance(dropdown_widget, QComboBox)
self.dropdown_widget = dropdown_widget
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout(self.rows_container)
self.rows_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.rows_container)
self.sub_forms: list[SubForm] = []
self.dropdown_widget.currentTextChanged.connect(self._selection_changed_changed)
def _selection_changed_changed(
self,
text: str,
) -> None:
current_count = len(self.sub_forms)
value: int
if text == DROPDOWN_DEFAULT:
value = 0
else:
value = int(text)
if value > current_count:
diff = value - current_count
for _ in range(diff):
self._add_row()
elif value < current_count:
diff = current_count - value
for _ in range(diff):
self._remove_row()
def _add_row(self) -> None:
number_form = len(self.sub_forms) + 1
container = QWidget()
container.setContentsMargins(0, 0, 0, 0)
form_layout = QFormLayout(container)
form_layout.setContentsMargins(10, 0, 0, 0)
sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form)
form_field_def = copy.copy(self.assigned_form_field)
form_field_def.label = form_field_def.enhanced_label(f"{number_form}")
sub_form.full_keys = _build_ui_recursively(
schema=[form_field_def],
parent_layout=form_layout,
widget_registry=sub_form.registry,
prefix=f"{self.prefix}",
)
self.rows_layout.addWidget(container)
self.sub_forms.append(sub_form)
self.update_sub_forms()
def _remove_row(self) -> None:
last_form = self.sub_forms.pop()
box_to_remove = last_form.entry_box
self.rows_layout.removeWidget(box_to_remove)
box_to_remove.deleteLater()
self.update_sub_forms()
def update_sub_forms(self) -> None:
update_sub_forms(
self.widget_registry,
sub_forms=self.sub_forms,
)
def reset(self) -> None:
# resets dynamic content when dropdown is set back to default value
self.dropdown_widget.setCurrentIndex(0)
def validate(self) -> list[str]:
errors = validate_form_data(self.widget_registry)
for form in self.sub_forms:
errors.extend(validate_form_data(form.registry))
return errors
def get_form_data(self) -> dict[str, Any]:
form_data = get_form_data(self.widget_registry)
sub_form_data_dicts = (get_form_data(sub.registry) for sub in self.sub_forms)
sub_data = merge_dicts_to_lists(sub_form_data_dicts)
form_data.update(sub_data)
logger_get_data.debug("Form data DynamicDropdownWidget:\n%s", pformat(form_data))
return form_data
def set_form_data(
self,
data: dict[str, Any],
) -> None:
# data is data of sub forms
# delete all rows
while self.sub_forms:
self._remove_row()
# fill in value of combobox field
assert len(self.full_keys) == 1
num_subforms: int = -1
for key in self.full_keys:
widget = self.widget_registry[key]["widget"]
value = data[key]
set_widget_value(widget, value)
if value is None:
num_subforms = 0
else:
num_subforms = value
del data[key]
logger_get_data.debug(">>>>>>>>> Call set_form_data for DynamicDropdown")
logger_get_data.debug("Data before unmerge:%s", pformat(data))
assert len(self.sub_forms) == num_subforms
if not self.sub_forms:
return
relevant_keys = self.sub_forms[-1].full_keys # all keys of sub forms are the same
sub_data = {key: data[key] for key in relevant_keys}
logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data))
if not sub_data:
return
sub_data = unmerge_dict_to_list(sub_data)
logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data))
assert len(sub_data) == len(self.sub_forms)
for data, sub_form in zip(sub_data, self.sub_forms):
set_form_data(sub_form.registry, data)
def lock(self) -> None:
lock_form(self.widget_registry)
for sub_form in self.sub_forms:
lock_form(sub_form.registry)
def unlock(self) -> None:
unlock_form(self.widget_registry)
for sub_form in self.sub_forms:
unlock_form(sub_form.registry)
class DynamicDropdownWidgetOption(QWidget):
"""
A Widget, which can generate and manage an arbitrary number of sub forms with additional
information on a combobox selection (combobox).
"""
def __init__(
self,
form_fields: Sequence[FormField],
trigger_value: str,
label_add_info: str = "Eintrag",
prefix: str = "",
):
super().__init__()
# form_fields = form_fields.children
if len(form_fields) == 0 or len(form_fields) > 1:
raise ValueError(
"Dynamic Dropdown Widget must have only one child, which is a dropdown widget"
)
self.combobox_field = form_fields[0]
assigned_form_fields = self.combobox_field.children
if len(assigned_form_fields) < 1:
raise ValueError(
(
"Option Dynamic Dropdown Widget dropdown element must have at least one "
"child field definition"
)
)
self.trigger_value = trigger_value
self.assigned_form_fields = assigned_form_fields
self.label_add_info = label_add_info
self.prefix = prefix
self.widget_registry: WidgetRegistry = {}
# layout for group component
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.form_layout = QFormLayout()
self.main_layout.addLayout(self.form_layout)
self.full_keys = _build_ui_recursively(
[self.combobox_field],
self.form_layout,
self.widget_registry,
prefix=f"{self.prefix}",
)
dropdown_widget_entry = tuple(self.widget_registry.values())[0]
dropdown_widget = dropdown_widget_entry["widget"]
assert isinstance(dropdown_widget, QComboBox)
self.dropdown_widget = dropdown_widget
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout(self.rows_container)
self.rows_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.rows_container)
self.sub_forms: list[SubForm] = []
self.dropdown_widget.currentTextChanged.connect(self._selection_changed_changed)
def _selection_changed_changed(
self,
text: str,
) -> None:
current_count = len(self.sub_forms)
value: int
if text == self.trigger_value:
value = 1
else:
value = 0
if value > current_count:
diff = value - current_count
for _ in range(diff):
self._add_row()
elif value < current_count:
diff = current_count - value
for _ in range(diff):
self._remove_row()
def _add_row(self) -> None:
number_form = len(self.sub_forms) + 1
container = QWidget()
container.setContentsMargins(0, 0, 0, 0)
form_layout = QFormLayout(container)
form_layout.setContentsMargins(10, 0, 0, 0)
sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form)
form_field_def = copy.copy(self.assigned_form_fields)
sub_form.full_keys = _build_ui_recursively(
schema=form_field_def,
parent_layout=form_layout,
widget_registry=sub_form.registry,
prefix=f"{self.prefix}",
)
self.rows_layout.addWidget(container)
self.sub_forms.append(sub_form)
self.update_sub_forms()
def _remove_row(self) -> None:
last_form = self.sub_forms.pop()
box_to_remove = last_form.entry_box
self.rows_layout.removeWidget(box_to_remove)
box_to_remove.deleteLater()
self.update_sub_forms()
def update_sub_forms(self) -> None:
update_sub_forms(
self.widget_registry,
sub_forms=self.sub_forms,
)
def _get_combined_registry(self, sub_forms_only: bool = False) -> WidgetRegistry:
whole_registry: WidgetRegistry = {}
if not sub_forms_only:
whole_registry = self.widget_registry.copy()
for sub in self.sub_forms:
whole_registry.update(sub.registry)
return whole_registry
def reset(self) -> None:
# resets dynamic content when dropdown is set back to default value
self.dropdown_widget.setCurrentIndex(0)
def validate(self) -> list[str]:
errors = validate_form_data(self.widget_registry)
for form in self.sub_forms:
errors.extend(validate_form_data(form.registry))
return errors
def get_form_data(self) -> dict[str, Any]:
whole_registry = self._get_combined_registry()
form_data = get_form_data(whole_registry)
# for sub in self.sub_forms:
# form_data.update(get_form_data(sub.registry))
logger_get_data.debug(
"Form data DynamicDropdownWidgetOption:\n%s", pformat(form_data)
)
return form_data
def set_form_data(
self,
data: dict[str, Any],
) -> None:
# delete all rows
while self.sub_forms:
self._remove_row()
# fill in value of combobox field
assert len(self.full_keys) == 1
num_subforms: int = -1
data = data.copy()
for key in self.full_keys:
widget = self.widget_registry[key]["widget"]
value = data[key]
set_widget_value(widget, value)
logger_get_data.debug("Value: %s", value)
if value is None or value != self.trigger_value:
num_subforms = 0
else:
num_subforms = 1
del data[key]
whole_registry = self._get_combined_registry(sub_forms_only=True)
logger_get_data.debug("[DynamicDD-Option] Widget registry for subforms obtained")
logger_get_data.debug(
"[DynamicDD-Option] -- set_form_data -- Data before set of subforms:\n%s",
pformat(data),
)
assert len(self.sub_forms) == num_subforms
if not self.sub_forms:
return
set_form_data(whole_registry, data)
def lock(self) -> None:
lock_form(self.widget_registry)
for sub_form in self.sub_forms:
lock_form(sub_form.registry)
def unlock(self) -> None:
unlock_form(self.widget_registry)
for sub_form in self.sub_forms:
unlock_form(sub_form.registry)
class NoScrollFilter(QObject):
"""disables scrolling in fields"""
def eventFilter(
self,
obj: QObject,
event: QEvent,
) -> bool:
if event.type() == QEvent.Type.Wheel:
# ignored Qt gives event to parent (ScrollArea)
event.ignore()
# event was handled
return True
# propagate other events
return super().eventFilter(obj, event)
class HeaderCell(QLabel):
"""basic header for table like objects"""
def __init__(
self,
text: str,
font_size: int = 12,
):
super().__init__(text)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet(f"""
HeaderCell {{
background-color: #e2e8f0;
color: #475569;
font-weight: bold;
padding: 10px;
border-radius: 6px;
font-size: {font_size}px;
}}
""")
class ClickableCell(QFrame):
"""basic cell which can be clicked"""
def __init__(
self,
text: str,
):
super().__init__()
self.setStyleSheet("""
ClickableCell {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
ClickableCell:hover {
background-color: #eff6ff;
border: 1px solid #60a5fa;
}
""")
layout = QVBoxLayout(self)
label = QLabel(text)
label.setWordWrap(True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
class CompanyProfile_Consultation_ClickableCell(ClickableCell):
"""cell on the company profile for consultations"""
clicked = Signal(CompanyProfileConsultationEntry)
def __init__(
self,
text: str,
data_record: CompanyProfileConsultationEntry,
):
super().__init__(text=text)
self.data_record = data_record
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self.data_record)
class MainPage_ClickableCell(ClickableCell):
"""cell in the table on the startup screen"""
clicked = Signal(types.MainPageEntry)
def __init__(
self,
text: str,
data_record: types.MainPageEntry,
):
super().__init__(text=text)
self.data_record = data_record
logger_gui.debug(
"Initialised ClickableCell on MainPage with data record:\n%s",
pformat(self.data_record),
)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self.data_record)
class Page_NewEntry(QWidget):
"""view for new entry for initial recording ("Grunderfassung")"""
back_requested = Signal() # Signal back button
# TODO re-enable with correct settings
# company_requested = Signal(Page_InitRecCompany_State) # Signal "Unternehmen"
# person_requested = Signal(Page_InitRecPerson_State) # Signal "Individualperson"
consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch"
new_initrec_requested = Signal(Page_InitRec_State) # TODO: rename
def __init__(
self,
state: Page_NewEntry_State,
):
super().__init__()
self.STATE = state
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
# Zurück-Button
back_btn = QPushButton("← Zurück zur Übersicht")
back_btn.setFixedWidth(200)
back_btn.clicked.connect(lambda: self.back_requested.emit())
# Platzhalter für die Details
self.title_label = QLabel("Wählen Sie den Typ der Grunderfassung")
self.title_label.setStyleSheet(
"font-size: 24px; font-weight: bold; margin-top: 20px;"
)
btn_company = QPushButton("Unternehmen →")
btn_company.setFixedWidth(300)
btn_company.setFixedHeight(40)
btn_company.clicked.connect(self._request_initrec_company)
btn_person = QPushButton("Individualperson →")
btn_person.setFixedWidth(300)
btn_person.setFixedHeight(40)
btn_person.clicked.connect(self._request_initrec_person)
btn_consulting = QPushButton("Beratungsgespräch →")
btn_consulting.setFixedWidth(300)
btn_consulting.setFixedHeight(40)
btn_consulting.clicked.connect(self._request_consulting)
btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →")
btn_newinitrec.setFixedWidth(300)
btn_newinitrec.setFixedHeight(40)
btn_newinitrec.clicked.connect(self._request_new_initrec)
layout.addWidget(back_btn)
layout.addSpacing(15)
layout.addWidget(self.title_label)
layout.addSpacing(20)
layout.addWidget(btn_newinitrec)
layout.addSpacing(20)
layout.addWidget(btn_company)
layout.addWidget(btn_person)
layout.addSpacing(10)
layout.addWidget(btn_consulting)
# TODO configure correctly
def _request_initrec_company(self) -> None:
logger_gui.debug("[Page -- NewEntry] Call for company...")
# ids = EntityIds(type=RecordingType.COMPANY)
# req_state = Page_InitRecCompany_State(
# session=self.STATE.session,
# ids=ids,
# # un_id=None, # TODO remove
# locked=False,
# )
# logger_gui.debug("[Page -- InitRec] State to call: %s", req_state)
# self.company_requested.emit(req_state)
def _request_initrec_person(self) -> None:
logger_gui.debug("[Page -- NewEntry] Call for person...")
# ids = EntityIds(type=RecordingType.PERSON)
# req_state = Page_InitRecPerson_State(
# session=self.STATE.session,
# ids=ids,
# # pers_id=None, # TODO remove
# locked=False,
# )
# logger_gui.debug("[Page -- InitRec] State to call: %s", req_state)
# self.person_requested.emit(req_state)
def _request_consulting(self) -> None:
req_state = Page_Consulting_State(
session=self.STATE.session,
vorgang_id=None,
beratungs_typ=ConsultingType.PAUSCHAL,
locked=False,
)
logger_gui.debug("[Page -- InitRec] State to call: %s", req_state)
self.consulting_requested.emit(req_state)
def _request_new_initrec(self) -> None:
req_state = Page_InitRec_State(
session=self.STATE.session,
recording_type=RecordingType.WITH_COMPANY,
form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}),
locked=False,
)
logger_gui.debug("[Page -- NewInitRec] State to call (IDs): %s", req_state.ids)
self.new_initrec_requested.emit(req_state)
def _sync_state_to_GUI(self) -> None:
pass
def _sync_GUI_to_state(self) -> None:
pass
def validate(self) -> None:
pass
def load_state(
self,
new_state: Page_NewEntry_State,
) -> None:
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI()
def get_state(self) -> Page_NewEntry_State:
self._sync_GUI_to_state()
return self.STATE
def save_data(self) -> None:
pass
# CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config(
# pydantic_model=Grunderfassung_Unternehmen,
# # to_db=backend.initrec_company_to_db, # TODO remove
# # from_db=backend.initrec_company_from_db, # TODO remove
# ignored_keys=(
# "Metadaten_erstellung",
# "Metadaten_aktualisierung",
# ),
# form_fields=INITREC_COMP,
# add_buttons=False,
# )
# CONFIG_GRUNDERFASSUNG_PERSONEN: Final[Page_InitRec_Form_Config] = Page_InitRec_Form_Config(
# pydantic_model=Grunderfassung_Personen,
# # to_db=backend.initrec_person_to_db, # TODO remove
# # from_db=backend.initrec_person_from_db, # TODO remove
# ignored_keys=(
# "Metadaten_erstellung",
# "Metadaten_aktualisierung",
# ),
# form_fields=INITREC_PERSON,
# add_buttons=False,
# )
CUSTOM_WIDGETS: Final[dict[str, type[CustomFormWidget]]] = {
"grunderfassung_unternehmen_suche": Grunderfassung_SuchWidget,
}
class CollapsibleBox(QWidget):
def __init__(self, title="", parent=None):
super().__init__(parent)
# 1. Header-Button mit Pfeil
self.toggle_button = QToolButton()
self.toggle_button.setText(f" {title}")
self.toggle_button.setCheckable(True)
self.toggle_button.setChecked(False)
self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.toggle_button.setArrowType(Qt.ArrowType.RightArrow)
self.toggle_button.setStyleSheet(
"QToolButton { border: none; font-weight: bold; font-size: 13px; }"
)
self.toggle_button.toggled.connect(self._on_toggle)
# 2. Inhaltsbereich (standardmäßig ausgeblendet)
self.content_area = QWidget()
self.content_area.setVisible(False)
# Layout des Box-Containers
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 5, 0, 5)
main_layout.addWidget(self.toggle_button)
main_layout.addWidget(self.content_area)
def _on_toggle(self, checked: bool):
# Pfeilrichtung ändern & Inhalt ein-/ausblenden
self.toggle_button.setArrowType(
Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
)
self.content_area.setVisible(checked)
def setContentLayout(
self,
layout: QLayout,
):
self.content_area.setLayout(layout)
def setContentWidget(
self,
widget: QWidget,
):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0) # Keine zusätzlichen Ränder
layout.addWidget(widget)
self.setContentLayout(layout)
class Page_InitRec(QWidget):
back_main_requested = Signal() # back to main page
back_requested = Signal() # back button
update_triggered = Signal() # form saved (data changed for front page)
def __init__(
self,
state: Page_InitRec_State,
):
super().__init__()
self.STATE = state
# Hauptlayout der Seite
outer_layout = QHBoxLayout(self)
vert_layout = QVBoxLayout()
# main_layout.setContentsMargins(0, 0, 0, 0)
outer_layout.addStretch(1)
outer_layout.addLayout(vert_layout, stretch=100)
# outer_layout.addWidget(scroll_area, stretch=100)
outer_layout.addStretch(1)
# Optional: Damit der Container oben am Rand klebt
outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# --- HEADER ---
header_container = QWidget()
header_container.setMinimumWidth(700)
header_container.setMaximumWidth(1000)
header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
header_layout = QVBoxLayout(header_container)
header_layout.setContentsMargins(0, 0, 0, 10)
back_btn_main = QPushButton("← Zurück zur Übersicht")
back_btn_main.clicked.connect(lambda: self.back_main_requested.emit())
back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_main.setMinimumWidth(200)
back_btn_main.setMaximumWidth(200)
back_btn_step = QPushButton("← Zurück")
back_btn_step.clicked.connect(lambda: self.back_requested.emit())
back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_step.setMinimumWidth(200)
back_btn_step.setMaximumWidth(200)
if self.STATE.recording_type is RecordingType.WITH_COMPANY:
title = QLabel("Grunderfassung Unternehmen")
elif self.STATE.recording_type is RecordingType.WITHOUT_COMPANY:
title = QLabel("Grunderfassung Individualperson")
else:
raise RuntimeError(f"Unspecified recording type {self.STATE.recording_type}")
title.setStyleSheet("font-size: 20px; font-weight: bold;")
header_layout.setSpacing(5)
header_layout.addWidget(back_btn_step)
header_layout.addWidget(back_btn_main)
header_layout.addWidget(title)
vert_layout.addWidget(header_container)
if DEBUG:
separator1 = QFrame()
separator1.setFrameShape(QFrame.Shape.HLine)
separator1.setFrameShadow(QFrame.Shadow.Sunken)
header_layout.addWidget(separator1)
btn_gui2state = QPushButton("Sync GUI to state")
btn_gui2state.clicked.connect(self._sync_GUI_to_state)
btn_gui2state.setFixedHeight(35)
btn_gui2state.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
header_layout.addWidget(btn_gui2state)
btn_state2gui = QPushButton("Sync state to GUI")
btn_state2gui.clicked.connect(self._sync_state_to_GUI)
btn_state2gui.setFixedHeight(35)
btn_state2gui.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
header_layout.addWidget(btn_state2gui)
btn_print_state = QPushButton("Print State")
btn_print_state.clicked.connect(self._debug_print_state)
btn_print_state.setFixedHeight(35)
btn_print_state.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
header_layout.addWidget(btn_print_state)
# self.test_button = QPushButton("Initialisiere Laden")
# self.test_button.clicked.connect(self._load_from_id)
# self.test_button.setFixedHeight(35)
# self.test_button.setSizePolicy(
# QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
# )
# header_layout.addWidget(self.test_button)
# button_get = QPushButton("GET DATA")
# button_get.setFixedHeight(35)
# button_get.clicked.connect(self._get_form_data)
# header_layout.addWidget(button_get)
# button_set = QPushButton("SET DATA")
# button_set.setFixedHeight(35)
# button_set.clicked.connect(self._set_form_data)
# header_layout.addWidget(button_set)
# id_field_layout = QHBoxLayout()
# id_field_layout.setContentsMargins(0, 0, 0, 0)
# id_field_layout.setSpacing(5)
# id_field_label = QLabel("ID Datenbank:")
# self.id_field_input = QLineEdit()
# id_field_layout.addWidget(id_field_label)
# id_field_layout.addWidget(self.id_field_input)
# header_layout.addLayout(id_field_layout)
# button_db_index = QPushButton("Setze DB Index")
# button_db_index.setFixedHeight(35)
# button_db_index.clicked.connect(self._set_db_index)
# header_layout.addWidget(button_db_index)
separator2 = QFrame()
separator2.setFrameShape(QFrame.Shape.HLine)
separator2.setFrameShadow(QFrame.Shadow.Sunken)
header_layout.addWidget(separator2)
header_layout.addSpacing(5)
# --- BANNER ---
self.info_banners_container = QWidget()
self.info_banners: list[InfoBanner] = []
self.info_banners_layout = QVBoxLayout(self.info_banners_container)
self.info_banners_layout.setContentsMargins(0, 0, 0, 0)
self.info_banners_layout.setSpacing(6)
header_layout.addWidget(self.info_banners_container)
# --- BUTTONS ---
self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True)
modify_btn = self.edit_buttons.modify_button
assert modify_btn
self.modify_btn = modify_btn
reset_btn = self.edit_buttons.reset_btn
assert reset_btn
self.reset_btn = reset_btn
self.save_btn = self.edit_buttons.save_btn
self.delete_btn = self.edit_buttons.delete_btn
self.modify_btn.modify_activated.connect(self.unlock)
self.modify_btn.modify_deactivated.connect(self.lock)
self.save_btn.clicked.connect(self.save_data)
self.reset_btn.clicked.connect(self._reset)
self.delete_btn.clicked.connect(self._delete_data)
header_layout.addSpacing(5)
header_layout.addWidget(self.edit_buttons)
# --- MAIN CONTENT ---
container = QWidget()
# SCROLL AREA
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setMinimumWidth(700)
scroll_area.setMaximumWidth(1000)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# optional: remove frame to look more modern
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setWidget(container)
vert_layout.addWidget(scroll_area)
self.container_layout = QVBoxLayout(container)
self.container_layout.setContentsMargins(0, 0, 0, 0)
# --- AUTO FORM LAYOUT ---
# now we try to render the layout within the collapsable box
self.container_layout.addSpacing(20)
# ** person
self.initrec_form_person_box = CollapsibleBox("Angaben zur Person")
ent_type = EntityType.PERSON
self.initrec_form_person = InitRecForm(self.STATE.initrec_form_states[ent_type])
assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_person.STATE
self.STATE.child_modules.append(self.initrec_form_person)
self.initrec_form_person_box.setContentWidget(self.initrec_form_person)
self.container_layout.addWidget(self.initrec_form_person_box)
# container_layout.addSpacing(15)
# TODO trigger needed?
# self.initrec_form_person.update_triggered.connect(
# lambda: self.update_triggered.emit()
# )
# self.initrec_form_person.update_triggered.connect(self._auto_form_updated)
# --- CUSTOM LOGIC ---
# ** 'Bundesland' only if 'Inland' selected in 'Stammdaten'
person_location = search_widgets_by_key(
self.initrec_form_person.widget_registry,
f"Stammdaten{COLUMN_SEP}aufenthaltsort",
)
assert len(person_location) == 1
self.person_location = cast(QComboBox, person_location[0]["widget"])
assert isinstance(self.person_location, QComboBox)
self.person_location.currentIndexChanged.connect(self._custom_county_selection)
selection_county = search_widgets_by_key(
self.initrec_form_person.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland"
)
assert len(selection_county) == 1
self.selection_county = cast(QComboBox, selection_county[0]["widget"])
assert isinstance(self.selection_county, QComboBox)
self.selection_county.setProperty("styleClass", "stempel")
self.selection_county.setEnabled(False)
# ** company
ent_type = EntityType.COMPANY
self.initrec_form_company_box = CollapsibleBox("Angaben zum Unternehmen")
self.initrec_form_company = InitRecForm(self.STATE.initrec_form_states[ent_type])
self.STATE.child_modules.append(self.initrec_form_company)
assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_company.STATE
target_idx = self.container_layout.indexOf(self.initrec_form_person_box) - 1
self.initrec_form_company_box.setContentWidget(self.initrec_form_company)
self.container_layout.insertWidget(target_idx, self.initrec_form_company_box)
# container_layout.addSpacing(15)
# TODO trigger needed?
# self.initrec_form_person.update_triggered.connect(
# lambda: self.update_triggered.emit()
# )
# self.initrec_form_person.update_triggered.connect(self._auto_form_updated)
# --- CUSTOM LOGIC ---
# ** fill 'Kontaktperson -> Namen Unternehmen'
search_res = search_widgets_by_key(
self.initrec_form_company.widget_registry, "Partnersuche"
)
assert len(search_res) == 1
search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"])
self.search_widget_trigger = cast(
QLineEdit, search_widget.company_widgets["ma_unternehmensname"]
)
self.container_layout.addStretch()
self._update_layout()
def _debug_print_state(self) -> None:
logger_initrecform.info(
"[Page -- InitRec] Printing state IDs:\n%s", pformat(self.STATE.ids)
)
def _update_layout(self) -> None:
if self.STATE.recording_type is RecordingType.WITH_COMPANY:
# add company
self.initrec_form_company_box.setVisible(True)
else:
self.initrec_form_company_box.setVisible(False)
# elif self.initrec_form_company_box is not None:
# self.container_layout.removeWidget(self.initrec_form_company_box)
# # ** state
# assert self.initrec_form_company
# self.STATE.child_modules.remove(self.initrec_form_company)
# del self.STATE.initrec_form_states[EntityType.COMPANY]
# self.initrec_form_company_box = None
# self.initrec_form_company = None
# def _set_new_initrec_state(
# self,
# ent_type: EntityType,
# ) -> None:
# initrec_form_state = Page_InitRec_Form_State(
# session=self.STATE.session,
# cfg=INITREC_CONFIGS[ent_type],
# ids=self.STATE.ids,
# ent_type=ent_type,
# locked=False,
# )
# self.STATE.initrec_form_states[ent_type] = initrec_form_state
# ** custom logic
def _custom_county_selection(self, idx: int) -> None:
value = self.person_location.itemData(idx)
if value == "Inland":
self.selection_county.setEnabled(True)
self.selection_county.setProperty("styleClass", "")
self.selection_county.style().unpolish(self.selection_county)
self.selection_county.style().polish(self.selection_county)
self.selection_county.update()
else:
self.selection_county.setCurrentIndex(0)
self.selection_county.setEnabled(False)
self.selection_county.setProperty("styleClass", "stempel")
self.selection_county.style().unpolish(self.selection_county)
self.selection_county.style().polish(self.selection_county)
self.selection_county.update()
# ** GUI behaviour
def _clear_info_banners(self) -> None:
while self.info_banners:
banner = self.info_banners[0]
self.info_banners_layout.removeWidget(banner)
self.info_banners.remove(banner)
banner.deleteLater()
def _add_info_banner(
self,
text: str,
button_text: str,
button_func: Callable[[], None] | None = None,
) -> None:
if button_text and button_func is None:
raise ValueError("Can not add button without calling function")
banner = InfoBanner(
text=text,
button_text=button_text,
)
if button_func is not None:
banner.btn_clicked.connect(button_func)
self.info_banners.append(banner)
self.info_banners_layout.addWidget(banner)
def _disable_save(self) -> None:
assert self.save_btn is not None
assert self.edit_buttons is not None
self.save_btn.setEnabled(False)
self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled)
def _enable_save(
self,
timeout: int = 3000,
) -> None:
assert self.save_btn is not None
assert self.edit_buttons is not None
QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True))
QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S"))
self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled)
def _activate_delete(self) -> None:
if self._deletion_valid():
self.delete_btn.setEnabled(True)
else:
self.delete_btn.setEnabled(False)
def _deletion_valid(self) -> bool:
# valid_id_states: list[bool] = []
relevant_ent_types: set[EntityType] = {EntityType.METADATA, EntityType.PERSON}
if self.STATE.recording_type is RecordingType.WITH_COMPANY:
relevant_ent_types.add(EntityType.COMPANY)
valid_id_states = (
form.relevant_ids_set()
for form in self.STATE.child_modules
if form.STATE.ent_type in relevant_ent_types
)
return all(valid_id_states)
def _delete_data(self) -> None:
assert self._deletion_valid(), "deletion initialised despite IDs are not properly set"
confirm = get_user_confirmation(
self,
"Löschen bestätigen",
"Soll der Eintrag wirklich gelöscht werden?",
)
if not confirm:
return
self.STATE.geloescht = True
self.save_data()
self.update_triggered.emit()
def _auto_form_updated(self) -> None:
self._sync_GUI_to_state()
self._sync_state_to_GUI("banners")
# TODO saving logic to connect company and person if needed
def _reset(self) -> None:
# TODO change to make these calls implicit in child modules via
# TODO staes
for form in self.STATE.child_modules:
form.reset_form()
self.STATE.ids = EntityIds()
self.STATE.geloescht = False
def _load_database(
self,
ids: EntityIds,
) -> None:
logger_initrecform.info("[Page -- InitRec] Load data method...")
logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids)
self._reset()
ids = EntityIds(
# un_id=3,
# an_id=7,
pers_id=5,
)
ids = EntityIds(
link_id=7,
)
self.STATE.ids.update(ids, ignore_none=False)
if ids.empty():
logger_initrecform.debug("[Page -- InitRec] Loading: No ID set. Do nothing!")
return
# TODO add loading logic with backend call
# !! just a dummy
initrec = backend.page_initrec_from_db(ids)
# TODO re-enable later
# if res.status != STATUS_HANDLER.SUCCESS:
# logger_page_consulting.error(
# (
# "[Consulting Page] There were errors during the database "
# "loading procedure."
# ),
# stack_info=True,
# )
# exc_formatted = (
# f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}"
# )
# msg_box = get_message_box(
# QMessageBox.Icon.Warning,
# "Laden fehlgeschlagen",
# (
# "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind "
# "unten angefügt."
# ),
# detailed_text=exc_formatted,
# )
# msg_box.exec()
# return
self._load_from_pydantic(initrec, sync_to_GUI=False)
def _load_from_pydantic(
self,
data: InitRec,
update_only: bool = False,
sync_to_GUI: bool = True,
) -> None:
logger_initrecform.info("[Page -- InitRec] Load from Pydantic model...")
self.STATE.ids.update(data.ids)
self.STATE.recording_type = data.recording_type
self.STATE.form_data = data.form_data
self.STATE.geloescht = True if data.geloescht else False
# set form data to correct initrec form states
for ent_type, state in self.STATE.initrec_form_states.items():
state.ids.update(self.STATE.ids)
state.geloescht = self.STATE.geloescht
match ent_type:
case EntityType.COMPANY:
state.form_data = self.STATE.form_data.company
if (
not update_only
and self.STATE.recording_type is RecordingType.WITH_COMPANY
):
self.initrec_form_company.load_state(state)
case EntityType.PERSON:
state.form_data = self.STATE.form_data.person
if not update_only:
self.initrec_form_person.load_state(state)
case _:
raise RuntimeError(f"Unspecified entity type {ent_type}")
if sync_to_GUI:
self._sync_state_to_GUI(None)
def _sync_state_to_GUI(
self,
section: Literal["content", "banners", "locking"] | None,
) -> None:
logger_page_initrec.debug(
"[Page -- InitRec] Syncing state to GUI: State IDs: %s", self.STATE.ids
)
if section is None or section == "content":
if not self.STATE.geloescht:
self._load_database(self.STATE.ids)
else:
self._reset()
self._update_layout()
if section is None or section == "banners":
self._clear_info_banners()
if self.STATE.ids.pers_id is None:
self._add_info_banner(
text="⚠️ Dieser Eintrag ist noch nicht gespeichert.",
button_text="",
)
if section is None or section == "locking":
if self.STATE.locked:
for form in self.STATE.child_modules:
form.lock()
self.edit_buttons.set_modify_state(False, set_delete_btn=True)
else:
for form in self.STATE.child_modules:
form.unlock()
self.edit_buttons.set_modify_state(True, set_delete_btn=False)
self._activate_delete()
def _sync_GUI_to_state(self) -> None:
logger_page_initrec.debug("[Page -- InitRec] Syncing GUI to state...")
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: Child modules...\n%s",
pformat(self.STATE.child_modules),
)
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: Existing form states keys...\n%s",
pformat(self.STATE.initrec_form_states.keys()),
)
for form in self.STATE.child_modules:
# ?? is this the same reference to use a similar pattern as
# ?? the consulting page (initialise once with the same state
# ?? as reference which are then automatically updated)
# !! check if this is working as expected
# new entries' states are linked to this instance by the page state
form_state = form.get_state()
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: Form state ent_type %s",
form_state.ent_type,
)
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: identity of form states: %s",
(self.STATE.initrec_form_states[form_state.ent_type] is form_state),
)
assert self.STATE.initrec_form_states[form_state.ent_type] is form_state, (
"passing by reference not working"
)
self.STATE.ids.update(form_state.ids, ignore_none=True)
# self.STATE.form_data[form_state.ent_type] = form_state.form_data
match form_state.ent_type:
case EntityType.COMPANY:
self.STATE.form_data.company = form_state.form_data
case EntityType.PERSON:
self.STATE.form_data.person = form_state.form_data
case _:
raise RuntimeError(f"Unspecified entity type {form_state.ent_type}")
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: IDs after...\n%s",
pformat(self.STATE.ids),
)
logger_page_initrec.debug(
"[Page -- InitRec] Syncing GUI to state: Form data after...\n%s",
pformat(self.STATE.form_data),
)
def load_state(
self,
new_state: Page_InitRec_State,
) -> None:
set_page_state(
self.STATE,
new_state,
ignore_attr={"form_data", "initrec_form_states"},
)
self._sync_state_to_GUI(None)
def get_state(self) -> Page_InitRec_State:
self._sync_GUI_to_state()
return self.STATE
def validate(self) -> list[str]:
errors: list[str] = []
for m in self.STATE.child_modules:
errors.extend(m.validate())
return errors
def lock(self) -> None:
logger_page_initrec.debug("[Page -- InitRec] Locking...")
self.STATE.locked = True
self._sync_state_to_GUI("locking")
def unlock(self) -> None:
logger_page_initrec.debug("[Page -- InitRec] Unlocking...")
self.STATE.locked = False
self._sync_state_to_GUI("locking")
def save_data(self) -> None:
logger_page_initrec.debug("[Page -- InitRec] Save button clicked...")
logger_page_initrec.info("[Page -- InitRec] Syncing current state...")
# state = self.get_state()
self._sync_GUI_to_state()
form_data = self.STATE.form_data
assert form_data, "form data not set"
logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data))
db_data = Page_InitRec_Form_Data(metadata={}, person={}, company={})
if self.STATE.ids.empty() and self.STATE.geloescht:
logger_page_initrec.debug(
"[Page -- InitRec] This was never saved in the database "
"and is marked for deletion - ignore."
)
return
elif not self.STATE.ids.empty() and self.STATE.geloescht:
logger_page_initrec.debug(
"[Page -- InitRec] This was saved in the database "
"and is marked for deletion - fast path."
)
deleted_datetime = datetime.datetime.now(datetime.UTC)
assert self.STATE.form_data, "cannot delete entry with uninitialised form data"
data_to_db = InitRec(
ids=self.STATE.ids,
recording_type=self.STATE.recording_type,
geloescht=deleted_datetime,
form_data=db_data,
)
returned = backend.page_initrec_to_db(data_to_db)
self._load_from_pydantic(returned)
msg_box = get_message_box(
QMessageBox.Icon.Information,
"Löschen erfolgreich",
"Der Eintrag wurde erfolgreich gelöscht",
)
msg_box.exec()
return
self._disable_save()
errors = self.validate()
if errors:
logger_page_initrec.error(
"[Page -- InitRec] Error during GUI validation phase:\n%s", pformat(errors)
)
GUI_validation_error_handling(errors)
self._enable_save()
return
logger_page_initrec.info("[Page -- InitRec] Success! All data is valid")
try:
logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...")
# validated_data = self.cfg.pydantic_model(**form_data)
for ent_type, state in self.STATE.initrec_form_states.items():
logger_page_initrec.debug(
"[Page -- InitRec] Ent type: %s, Current state CFG model: %s",
ent_type,
state.cfg.pydantic_model,
)
match ent_type:
case EntityType.COMPANY:
val_data = state.cfg.pydantic_model(**self.STATE.form_data.company)
db_data.company = val_data.to_db(exclude=state.cfg.ignored_keys)
case EntityType.PERSON:
val_data = state.cfg.pydantic_model(**self.STATE.form_data.person)
db_data.person = val_data.to_db(exclude=state.cfg.ignored_keys)
case _:
raise RuntimeError(f"Unspecified entity type {ent_type}")
except ValidationError as e:
logger_page_initrec.error(
"[Page -- InitRec] Error during Pydantic validation phase:\n%s",
pformat(e.errors()),
)
pydantic_validation_error_handling(e)
else:
if DEBUG_NO_DATABASE:
return
# !! this code is only called if the 'try' block was successful
# save data to database
# db_data = validated_data.to_db(exclude=self.cfg.ignored_keys)
logger_page_initrec.debug(
"[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s",
pformat(db_data),
)
deleted_datetime: datetime.datetime | None = None
if self.STATE.geloescht:
deleted_datetime = datetime.datetime.now(datetime.UTC)
try:
# TODO change to result pattern
data_to_db = InitRec(
ids=self.STATE.ids,
recording_type=self.STATE.recording_type,
geloescht=deleted_datetime,
form_data=db_data,
)
returned = backend.page_initrec_to_db(data_to_db)
self._load_from_pydantic(returned, update_only=True, sync_to_GUI=False)
self.update_triggered.emit()
except Exception as err:
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Speichern fehlgeschlagen",
(
"Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind "
"unten angefügt."
),
detailed_text=str(err),
)
msg_box.exec()
else:
logger_page_initrec.info("[Page -- InitRec] Data saved successfully")
msg_box = get_message_box(
QMessageBox.Icon.Information,
"Speichern erfolgreich",
"Die Daten wurden erfolgreich gespeichert",
)
msg_box.exec()
finally:
# always re-enable save, even if error occurred
self._enable_save()
class Page_CompanyProfile(QWidget):
back_main_requested = Signal() # back to main page
back_requested = Signal() # back to init rec (bool: reset yes/no)
# TODO update should trigger something else
# more like listening to 'child' ("Beratungen")
# update_triggered = Signal() # form saved (data changed for front page)
consulting_requested = Signal(Page_Consulting_State)
def __init__(
self,
state: Page_CompanyProfile_State,
):
super().__init__()
self.STATE = state
# state management
self.COMPANY_FEATURES_MASTER_DATA: dict[str, str] = {
"ma_unternehmensname": "Name:",
"ma_branche": "Branche:",
"ma_strasse": "Straße:",
"ma_hausnummer": "Hausnummer:",
"ma_plz": "PLZ:",
"ma_ort": "Ort:",
"ma_website": "Webseite:",
"ma_mail": "E-Mail:",
"ma_telefonnummer": "Telefon:",
}
# Hauptlayout der Seite
outer_layout = QHBoxLayout(self)
vert_layout = QVBoxLayout()
# main_layout.setContentsMargins(0, 0, 0, 0)
outer_layout.addStretch(1)
outer_layout.addLayout(vert_layout, stretch=100)
# outer_layout.addWidget(scroll_area, stretch=100)
outer_layout.addStretch(1)
# Optional: Damit der Container oben am Rand klebt
outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# --- HEADER ---
header_container = QWidget()
header_container.setMinimumWidth(700)
header_container.setMaximumWidth(1200)
header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
header_layout = QVBoxLayout(header_container)
header_layout.setContentsMargins(0, 0, 0, 10)
back_btn_main = QPushButton("← Zurück zur Übersicht")
back_btn_main.clicked.connect(lambda: self.back_main_requested.emit())
back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_main.setMinimumWidth(200)
back_btn_main.setMaximumWidth(200)
back_btn_step = QPushButton("← Zurück")
# back_btn_step.clicked.connect(self._request_initrec_company) # TODO remove
back_btn_step.clicked.connect(lambda: self.back_requested.emit())
back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_step.setMinimumWidth(200)
back_btn_step.setMaximumWidth(200)
title = QLabel("Unternehmensprofil")
title.setStyleSheet("font-size: 20px; font-weight: bold;")
header_layout.setSpacing(5)
header_layout.addSpacing(15)
header_layout.addWidget(back_btn_step)
header_layout.addWidget(back_btn_main)
header_layout.addWidget(title)
vert_layout.addWidget(header_container)
# --- MAIN CONTENT ---
container = QWidget()
# SCROLL AREA
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setMinimumWidth(700)
scroll_area.setMaximumWidth(1200)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# optional: remove frame to look more modern
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setWidget(container)
vert_layout.addWidget(scroll_area)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
# --- MAIN LAYOUT ---
if DEBUG:
separator1 = QFrame()
separator1.setFrameShape(QFrame.Shape.HLine)
separator1.setFrameShadow(QFrame.Shadow.Sunken)
container_layout.addWidget(separator1)
layout_change_title = QHBoxLayout()
self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...")
layout_change_title.addWidget(self.change_title_text)
change_title_btn = QPushButton("ändern")
layout_change_title.addWidget(change_title_btn)
change_title_btn.clicked.connect(self.set_company_name)
container_layout.addLayout(layout_change_title)
btn_dummy_pauschal = QPushButton("Dummy Pauschalberatung hinzufügen")
btn_dummy_pauschal.clicked.connect(
lambda: self._debug_consulting_dummy(ConsultingType.PAUSCHAL)
)
container_layout.addWidget(btn_dummy_pauschal)
btn_dummy_inidividual = QPushButton("Dummy Individualberatung hinzufügen")
btn_dummy_inidividual.clicked.connect(
lambda: self._debug_consulting_dummy(ConsultingType.INDIVIDUAL)
)
container_layout.addWidget(btn_dummy_inidividual)
btn_clear_cons = QPushButton("Beratungen leeren")
btn_clear_cons.clicked.connect(self._clear_consultations)
container_layout.addWidget(btn_clear_cons)
btn_update_cons = QPushButton("Beratungen aktualisieren")
btn_update_cons.clicked.connect(self._update_consultations)
container_layout.addWidget(btn_update_cons)
separator2 = QFrame()
separator2.setFrameShape(QFrame.Shape.HLine)
separator2.setFrameShadow(QFrame.Shadow.Sunken)
container_layout.addWidget(separator2)
container_layout.addSpacing(30)
self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16)
container_layout.addWidget(self.title_company_name)
container_layout.addSpacing(20)
# separation in left and right
main_hor_layout = QHBoxLayout()
main_hor_layout.setSpacing(20)
# main_hor_layout.setContentsMargins(20, 20, 20, 20)
# left column
left_frame = QFrame()
left_frame.setObjectName("Segment")
left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
left_layout = QVBoxLayout(left_frame)
main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop)
master_data_label = HeaderCell("Stammdaten & Vermerke")
master_data_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
left_layout.addWidget(master_data_label)
default_labels = tuple(self.COMPANY_FEATURES_MASTER_DATA.values())
self.table_master_data = MasterDataFormWidget(default_labels)
left_layout.addWidget(self.table_master_data)
left_layout.addWidget(QLabel("PLATZHALTER Vermerke: Welche Notizen?"))
# right column
right_column_layout = QVBoxLayout()
right_column_layout.setSpacing(20)
main_hor_layout.addLayout(right_column_layout, stretch=1)
# top right area
top_right_frame = QFrame()
top_right_frame.setObjectName("Segment")
top_right_frame.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
)
self.layout_cons_pauschal = QVBoxLayout(top_right_frame)
self.cons_pauschal: list[CompanyProfile_Consultation_ClickableCell] = []
common_consultancy_label = HeaderCell("Pauschalberatung")
common_consultancy_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.layout_cons_pauschal.addWidget(common_consultancy_label)
self.add_btn_pauschal = QPushButton("+ Hinzufügen")
self.add_btn_pauschal.clicked.connect(
lambda: self._request_new_consulting(ConsultingType.PAUSCHAL)
)
self.add_btn_pauschal.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.add_btn_pauschal.setMinimumHeight(30)
self.add_btn_pauschal.setProperty("styleClass", "flatAddButton")
self.layout_cons_pauschal.addWidget(self.add_btn_pauschal)
# bottom right area
bottom_right_frame = QFrame()
bottom_right_frame.setObjectName("Segment")
bottom_right_frame.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
)
self.layout_cons_indiv = QVBoxLayout(bottom_right_frame)
self.cons_indiv: list[CompanyProfile_Consultation_ClickableCell] = []
individual_consultancy_label = HeaderCell("Individualberatung")
individual_consultancy_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.layout_cons_indiv.addWidget(individual_consultancy_label)
self.add_btn_individual = QPushButton("+ Hinzufügen")
self.add_btn_individual.clicked.connect(
lambda: self._request_new_consulting(ConsultingType.INDIVIDUAL)
)
self.add_btn_individual.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.add_btn_individual.setMinimumHeight(30)
self.add_btn_individual.setProperty("styleClass", "flatAddButton")
self.layout_cons_indiv.addWidget(self.add_btn_individual)
right_column_layout.addWidget(top_right_frame)
right_column_layout.addWidget(bottom_right_frame)
right_column_layout.addStretch(1)
container_layout.addLayout(main_hor_layout)
container_layout.addStretch()
# --- STYLE ---
self.setStyleSheet("""
QFrame#Segment {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 3px;
}
""")
# self.setStyleSheet("""
# /* Hintergrund des Hauptfensters */
# QWidget {
# background-color: #f1f5f9;
# }
# /* Styling für unsere 3 Segmente */
# QFrame#Segment {
# background-color: #ffffff;
# border: 1px solid #e2e8f0;
# border-radius: 12px;
# padding: 15px;
# }
# /* Text-Styling */
# QLabel {
# color: #334155;
# font-size: 16px;
# font-weight: bold;
# }
# """)
def _debug_consulting_dummy(
self,
type: ConsultingType,
):
if type is ConsultingType.PAUSCHAL:
dummy_click_cell = CompanyProfile_Consultation_ClickableCell(
"Dummy Pauschal",
None, # type: ignore
)
target_layout = self.layout_cons_pauschal
self.cons_pauschal.append(dummy_click_cell)
else:
dummy_click_cell = CompanyProfile_Consultation_ClickableCell(
"Dummy Individual",
None, # type: ignore
)
target_layout = self.layout_cons_indiv
self.cons_indiv.append(dummy_click_cell)
target_idx = target_layout.count() - 1
target_layout.insertWidget(target_idx, dummy_click_cell)
# def _request_initrec_company(self) -> None:
# req_state = Page_InitRecCompany_State(
# session=self.STATE.session,
# un_id=self.STATE.rec_id,
# locked=True,
# )
# logger_gui.debug("[Page Company Profile] State to call: %s", req_state)
# self.back_requested.emit(req_state)
def _request_new_consulting(
self,
cons_type: ConsultingType,
) -> None:
assert self.STATE.ids.un_id is not None, (
"tried to call consulting from comp profile without set ID"
)
new_state = Page_Consulting_State(
session=self.STATE.session,
vorgang_id=None,
un_id=self.STATE.ids.un_id,
beratungs_typ=cons_type,
locked=False,
)
self.consulting_requested.emit(new_state)
def _request_existing_consulting(
self,
data: CompanyProfileConsultationEntry,
) -> None:
assert self.STATE.ids.un_id is not None, (
"tried to call consulting from comp profile without set ID"
)
new_state = Page_Consulting_State(
session=self.STATE.session,
vorgang_id=data.cons_id,
un_id=self.STATE.ids.un_id,
beratungs_typ=data.cons_type,
locked=True,
)
self.consulting_requested.emit(new_state)
def _clear_consultations(self) -> None:
logger_gui.debug("[Page Company Profile] Clear consultations...")
while self.cons_pauschal:
widget = self.cons_pauschal[0]
self.layout_cons_pauschal.removeWidget(widget)
self.cons_pauschal.remove(widget)
widget.deleteLater()
while self.cons_indiv:
widget = self.cons_indiv[0]
self.layout_cons_indiv.removeWidget(widget)
self.cons_indiv.remove(widget)
widget.deleteLater()
def _update_consultations(self) -> None:
if self.STATE.ids.un_id is None:
return
consultations = backend.companyprofile_page_get_consultations(
un_id=self.STATE.ids.un_id
)
logger_gui.debug(
"[Page -- Company Profile] Consultations: %s", pformat(consultations)
)
self._clear_consultations()
for entry in consultations.pauschal:
date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M")
display_title = f"Pauschalberatung ({date_formatted}): {entry.title}"
click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry)
click_cell.clicked.connect(self._request_existing_consulting)
target_idx = self.layout_cons_pauschal.count() - 1
self.layout_cons_pauschal.insertWidget(target_idx, click_cell)
self.cons_pauschal.append(click_cell)
for entry in consultations.individual:
date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M")
display_title = f"Pauschalberatung ({date_formatted}): {entry.title}"
click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry)
click_cell.clicked.connect(self._request_existing_consulting)
target_idx = self.layout_cons_indiv.count() - 1
self.layout_cons_indiv.insertWidget(target_idx, click_cell)
self.cons_indiv.append(click_cell)
def update_current_page(self) -> None:
self._update_consultations()
def _disable_add_btns(self) -> None:
self.add_btn_pauschal.setEnabled(False)
self.add_btn_individual.setEnabled(False)
def _enable_add_btns(self) -> None:
self.add_btn_pauschal.setEnabled(True)
self.add_btn_individual.setEnabled(True)
def _sync_state_to_GUI(self) -> None:
if self.STATE.ids.un_id is None:
# just clear and do nothing else
logger_gui.debug("just reset, ID is None")
self.table_master_data.reset()
self.set_company_name("")
self._disable_add_btns()
return
self._enable_add_btns()
# TODO change to correct backend function
raise RuntimeError("TODOs to implement")
init_rec = backend.initrec_company_get_initial_recording(self.STATE.ids.un_id)
ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"])
assert ma_id is not None, "no ma_id obtained"
comp_info = backend.initrec_comp_search_get_info(ma_id)
data: dict[str, Any] = {}
for feature, value in comp_info.items():
if feature in self.COMPANY_FEATURES_MASTER_DATA:
data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value
self.table_master_data.load_data(data)
self.set_company_name(data["Name:"])
self.update_current_page()
def _sync_GUI_to_state(self) -> None:
pass
def validate(self) -> None:
pass
def load_state(
self,
new_state: Page_CompanyProfile_State,
) -> None:
logger_gui.debug("Called function to load with IDs: %s", pformat(new_state.ids))
self.STATE.ids = new_state.ids
self._sync_state_to_GUI()
def get_state(self) -> Any: ...
def save_data(self) -> None: ...
def set_company_name(self, name: str) -> None:
if not name:
name = self.change_title_text.text()
if not name:
name = "n.v."
txt = f"Unternehmen: {name}"
self.title_company_name.setText(txt)
class MasterDataTable(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Stammdaten Anzeige")
self.resize(450, 400)
layout = QVBoxLayout(self)
layout.setContentsMargins(15, 15, 15, 15)
# 1. Tabelle erstellen (0 Zeilen initial, 2 Spalten für Merkmal & Wert)
self.table = QTableWidget(0, 2)
# --- DER TRICK FÜR DEN CLEANEN LOOK ---
self.table.horizontalHeader().hide() # Spalten-Header ausblenden
self.table.verticalHeader().hide() # Zeilen-Header (Nummerierung) ausblenden
self.table.setShowGrid(False) # Gitternetzlinien deaktivieren
# Verhalten der Tabelle anpassen
self.table.setSelectionMode(
QAbstractItemView.SelectionMode.NoSelection
) # Nicht auswählbar
self.table.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers
) # Schreibgeschützt
# Spaltenbreite steuern:
# Linke Spalte passt sich dem Text an, rechte Spalte füllt den Rest des Fensters
self.table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeMode.ResizeToContents
)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.table)
# Daten laden
self.load_data()
self.style_table()
def load_data(self):
daten = [
("Anwendungsname", "MyQtApp Pro"),
("Version", "2.4.1-beta"),
("Datenbank-Pfad", "/var/lib/sqlite/data.db"),
("Letzter Login", "2026-06-25 09:30"),
("Benutzer-ID", "USR-88291"),
("Verschlüsselung", "AES-256-GCM"),
]
# Tabelle befüllen
self.table.setRowCount(len(daten))
for zeile_index, (merkmal, wert) in enumerate(daten):
# Merkmal-Zelle (Links)
item_merkmal = QTableWidgetItem(merkmal)
item_merkmal.setTextAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.table.setItem(zeile_index, 0, item_merkmal)
# Wert-Zelle (Rechts)
item_wert = QTableWidgetItem(wert)
item_wert.setTextAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.table.setItem(zeile_index, 1, item_wert)
def style_table(self):
self.setStyleSheet("""
QWidget {
background-color: transparent;
}
QTableWidget {
border: none;
background-color: transparent;
}
QTableWidget::item {
padding: 8px;
color: #334155;
font-size: 14px;
}
QTableWidget::item:first-column {
font-weight: bold;
color: #64748b;
padding-right: 20px; /* Mehr Abstand zum Wert */
}
""")
class MasterDataFormWidget(QWidget):
def __init__(
self,
default_labels: Sequence[str],
):
super().__init__()
self.default_labels = default_labels
self.default_data = {label: None for label in self.default_labels}
# main layout
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
# form in frame
segment_frame = QFrame()
segment_frame.setObjectName("Segment")
# form layout
self.form_layout = QFormLayout(segment_frame)
self.form_layout.setSpacing(15)
self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
main_layout.addWidget(segment_frame)
self.style_ui()
self.reset()
def load_data(
self,
data: dict[str, Any],
) -> None:
self.clear_current_layout()
for feature, value in data.items():
label_feature = QLabel(f"{feature}")
label_feature.setObjectName("Merkmal")
if value is None:
value = ""
label_value = QLabel(str(value))
label_value.setObjectName("Wert")
label_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.form_layout.addRow(label_feature, label_value)
def clear_current_layout(self):
while self.form_layout.count() > 0:
item = self.form_layout.takeAt(0)
assert item is not None
widget = item.widget()
if widget is not None:
widget.deleteLater()
def reset(self) -> None:
self.load_data(self.default_data)
def style_ui(self):
self.setStyleSheet("""
/* Hintergrund des Fensters */
QWidget {
background-color: transparent;
}
/* Unser optisch abgetrenntes Segment */
QFrame#Segment {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 5px;
}
/* Textgröße für alle Elemente im Formular hochsetzen */
QLabel {
font-size: 12px;
}
/* Linke Spalte (Merkmale): Grau und Fett */
QLabel#Merkmal {
font-weight: bold;
/* color: #64748b; */
color: #475569;
}
/* Rechte Spalte (Werte): Dunkel und gut lesbar */
QLabel#Wert {
color: #0f172a;
}
""")
class Page_Consulting(QWidget):
back_main_requested = Signal() # back to main page
back_requested = Signal() # back to init rec (bool: reset yes/no)
# TODO update should trigger something else
# more like listening to 'child' ("Beratungen")
update_triggered = Signal() # form saved (data changed for front page)
def __init__(
self,
session: Session,
):
super().__init__()
# state management
self.STATE = Page_Consulting_State(
session=session,
vorgang_id=None,
beratungs_typ=ConsultingType.PAUSCHAL,
locked=False,
)
# Hauptlayout der Seite
outer_layout = QHBoxLayout(self)
vert_layout = QVBoxLayout()
# main_layout.setContentsMargins(0, 0, 0, 0)
outer_layout.addStretch(1)
outer_layout.addLayout(vert_layout, stretch=100)
outer_layout.addStretch(1)
# Optional: Damit der Container oben am Rand klebt
outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# --- HEADER ---
header_container = QWidget()
header_container.setMinimumWidth(700)
header_container.setMaximumWidth(1500)
header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
header_layout = QVBoxLayout(header_container)
header_layout.setContentsMargins(0, 0, 0, 10)
back_btn_main = QPushButton("← Zurück zur Übersicht")
back_btn_main.clicked.connect(lambda: self.back_main_requested.emit())
back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_main.setMinimumWidth(200)
back_btn_main.setMaximumWidth(200)
back_btn_step = QPushButton("← Zurück")
back_btn_step.clicked.connect(lambda: self.back_requested.emit())
back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
back_btn_step.setMinimumWidth(200)
back_btn_step.setMaximumWidth(200)
title = QLabel("Beratungsgespräch")
title.setStyleSheet("font-size: 20px; font-weight: bold;")
header_layout.setSpacing(5)
header_layout.addSpacing(15)
header_layout.addWidget(back_btn_step)
header_layout.addWidget(back_btn_main)
header_layout.addWidget(title)
vert_layout.addWidget(header_container)
# --- MAIN CONTENT ---
container = QWidget()
# SCROLL AREA
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setMinimumWidth(700)
scroll_area.setMaximumWidth(1500)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# optional: remove frame to look more modern
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setWidget(container)
vert_layout.addWidget(scroll_area)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
# --- MAIN LAYOUT ---
if DEBUG:
separator1 = QFrame()
separator1.setFrameShape(QFrame.Shape.HLine)
separator1.setFrameShadow(QFrame.Shadow.Sunken)
container_layout.addWidget(separator1)
btn_get_data_table = QPushButton("Debug call 'get_state' from table and page")
btn_get_data_table.clicked.connect(self._debug_get_state)
container_layout.addWidget(btn_get_data_table)
btn_validate_data_table = QPushButton("Debug call 'validate' from table and page")
btn_validate_data_table.clicked.connect(self._debug_validate)
container_layout.addWidget(btn_validate_data_table)
db_index_layout = QHBoxLayout()
self.db_index_field = QLineEdit(placeholderText="setze Index für VorgangID")
self.db_index_field.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
db_index_set_button = QPushButton("setze und lade Vorgang-ID (Datenbankindex)")
db_index_set_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
db_index_set_button.clicked.connect(self._debug_load_database)
lock_button = QPushButton("Seite sperren")
lock_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
lock_button.clicked.connect(self._debug_load_database)
db_index_layout.addWidget(self.db_index_field)
db_index_layout.addWidget(db_index_set_button)
container_layout.addLayout(db_index_layout)
linking_button = QPushButton("Verlinkungsdialog öffnen")
linking_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
linking_button.clicked.connect(self._open_linking_dialogue)
container_layout.addWidget(linking_button)
separator2 = QFrame()
separator2.setFrameShape(QFrame.Shape.HLine)
separator2.setFrameShadow(QFrame.Shadow.Sunken)
container_layout.addWidget(separator2)
container_layout.addSpacing(30)
# --- BANNER ---
self.info_banners_container = QWidget()
self.info_banners: list[InfoBanner] = []
self.info_banners_layout = QVBoxLayout(self.info_banners_container)
self.info_banners_layout.setContentsMargins(0, 0, 0, 0)
self.info_banners_layout.setSpacing(6)
container_layout.addWidget(self.info_banners_container)
# --- BUTTONS ---
self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=False)
modify_btn = self.edit_buttons.modify_button
assert modify_btn
self.modify_btn = modify_btn
# reset_btn = self.edit_buttons.reset_btn
# assert reset_btn
self.save_btn = self.edit_buttons.save_btn
# self.reset_btn = reset_btn
self.delete_btn = self.edit_buttons.delete_btn
self.modify_btn.modify_activated.connect(self.unlock)
self.modify_btn.modify_deactivated.connect(self.lock)
self.save_btn.clicked.connect(self.save_data)
# self.reset_btn.clicked.connect(self.reset_form)
self.delete_btn.clicked.connect(self._delete_data)
container_layout.addWidget(self.edit_buttons)
type_group_box = QGroupBox("Typ")
type_layout = QVBoxLayout(type_group_box)
container_layout.addWidget(type_group_box)
self.type_pauschal_btn = QRadioButton("Pauschalberatung")
self.type_individual_btn = QRadioButton("Individualberatung")
self.type_pauschal_btn.setChecked(True)
self.type_group = QButtonGroup()
self.type_group.addButton(self.type_pauschal_btn, 0)
self.type_group.addButton(self.type_individual_btn, 1)
self.type_group.idClicked.connect(self.type_consulting_changed)
type_layout.addWidget(self.type_pauschal_btn)
type_layout.addWidget(self.type_individual_btn)
# container_layout.addSpacing(50)
table_state = Page_Consulting_Table_State(
session=self.STATE.session,
locked=self.STATE.locked,
)
self.table_sessions = Page_Consulting_Table(state=table_state)
self.STATE.child_modules.append(self.table_sessions)
container_layout.addWidget(self.table_sessions)
# --- STYLE ---
self.setStyleSheet("""
QFrame#Segment {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 3px;
}
""")
# self.setStyleSheet("""
# /* Hintergrund des Hauptfensters */
# QWidget {
# background-color: #f1f5f9;
# }
# /* Styling für unsere 3 Segmente */
# QFrame#Segment {
# background-color: #ffffff;
# border: 1px solid #e2e8f0;
# border-radius: 12px;
# padding: 15px;
# }
# /* Text-Styling */
# QLabel {
# color: #334155;
# font-size: 16px;
# font-weight: bold;
# }
# """)
self._sync_state_to_GUI(None)
def _lock_page(self) -> None:
pass
def _delete_data(self) -> None:
logger_page_consulting.debug("[Consulting Page] Call data delete...")
assert self.STATE.vorgang_id is not None, "deletion initialised despite no index set"
confirm = get_user_confirmation(
self,
"Löschen bestätigen",
"Soll der Eintrag wirklich gelöscht werden?",
)
if not confirm:
return
self.STATE.geloescht = True
self._delete_entry()
self.update_triggered.emit()
def _disable_save(self) -> None:
self.save_btn.setEnabled(False)
self.save_btn.setText(self.edit_buttons.save_btn_txt_disabled)
def _activate_delete(self) -> None:
# if self.STATE.un_id is not None or self.STATE.pers_id is not None:
if self.STATE.vorgang_id is not None:
self.delete_btn.setEnabled(True)
else:
self.delete_btn.setEnabled(False)
def _enable_save(
self,
timeout: int = 3000,
) -> None:
QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True))
QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S"))
self.save_btn.setText(self.edit_buttons.save_btn_txt_enabled)
def type_consulting_changed(
self,
button_id: int,
) -> None:
logger_page_consulting.debug("[Consulting Page] Trigger type buttons...")
cons_type: ConsultingType
if button_id == 0:
cons_type = ConsultingType.PAUSCHAL
elif button_id == 1:
cons_type = ConsultingType.INDIVIDUAL
else:
raise RuntimeError(f"No consulting type defined for button id: {button_id}")
logger_page_consulting.debug("[Consulting Page] Selected cons_type: %s", cons_type)
self.STATE.beratungs_typ = cons_type
self._sync_state_to_GUI("cons_type")
def _debug_get_state(self) -> None:
logger_page_consulting.debug("[Consulting Page] Call get state on table...")
_ = self.table_sessions.get_state()
logger_page_consulting.debug("[Consulting Page] Call get state on page...")
state = self.get_state()
logger_page_consulting.debug("[Consulting Page] Page state:\n%s", state)
def _debug_validate(self) -> None:
logger_page_consulting.debug("[Consulting Page] Call validate data on table...")
errors = self.table_sessions.validate()
logger_page_consulting.debug("[Consulting Page] Table errors:\n%s", pformat(errors))
logger_page_consulting.debug("[Consulting Page] Call get state on page...")
errors = self.validate()
logger_page_consulting.debug("[Consulting Page] Page errors:\n%s", pformat(errors))
def _debug_load_database(self) -> None:
value = self.db_index_field.text().strip()
if not value:
return
try:
db_index = int(value)
except ValueError:
return
self._load_database(db_index)
def _open_linking_dialogue(self) -> None:
logger_page_consulting.info("[Consulting Page] Linking dialogue triggered")
# Dialog erstellen und anzeigen
dialog_state = Page_Consulting_Linking_State(
session=self.STATE.session,
un_id=self.STATE.un_id,
pers_id=self.STATE.pers_id,
beratungs_typ=self.STATE.beratungs_typ,
)
dialog = Page_Consulting_LinkingDialog(dialog_state, parent=self)
# .exec() blocks main window
if dialog.exec() == QDialog.DialogCode.Accepted:
# Hier hast du am Ende beide (oder nur eine) Entitäten auswertbar vorliegen:
dialog_state = dialog.get_state()
self.STATE.un_id = dialog_state.un_id
self.STATE.pers_id = dialog_state.pers_id
assert self.STATE.vorgang_id is not None, (
"cannot link consulting process which is not already saved"
)
# TODO error handling
backend.page_consulting_eastablish_link(
vorgang_id=self.STATE.vorgang_id,
un_id=self.STATE.un_id,
pers_id=self.STATE.pers_id,
)
logger_page_consulting.info(
"[Consulting Page] Company selected (ID): %s, Person selected (ID): %s",
self.STATE.un_id,
self.STATE.pers_id,
)
else:
logger_page_consulting.info("[Consulting Page] Verknüpfung abgebrochen.")
self._sync_state_to_GUI("banners")
def _clear_info_banners(self) -> None:
while self.info_banners:
banner = self.info_banners[0]
self.info_banners_layout.removeWidget(banner)
self.info_banners.remove(banner)
banner.deleteLater()
def _add_info_banner(
self,
text: str,
button_text: str,
button_func: Callable[[], None] | None = None,
) -> None:
if button_text and button_func is None:
raise ValueError("Can not add button without calling function")
banner = InfoBanner(
text=text,
button_text=button_text,
)
if button_func is not None:
banner.btn_clicked.connect(button_func)
self.info_banners.append(banner)
self.info_banners_layout.addWidget(banner)
def _reset(self) -> None:
self.STATE.vorgang_id = None
self.STATE.cons_sessions.clear()
self.STATE.geloescht = False
def _sync_state_to_GUI(
self,
section: Literal["content", "cons_type", "banners", "table", "locking"] | None,
) -> None:
if section is None or section == "content":
if not self.STATE.geloescht:
self._load_database(self.STATE.vorgang_id)
else:
self._reset()
if section is None or section == "cons_type" or section == "locking":
match self.STATE.beratungs_typ:
case ConsultingType.PAUSCHAL:
self.type_pauschal_btn.setChecked(True)
case ConsultingType.INDIVIDUAL:
self.type_individual_btn.setChecked(True)
# deactivate if at least one ID is set (type is pre-defined by how
# the site is called)
if self.STATE.un_id or self.STATE.pers_id or self.STATE.locked:
self.type_pauschal_btn.setEnabled(False)
self.type_individual_btn.setEnabled(False)
else:
self.type_pauschal_btn.setEnabled(True)
self.type_individual_btn.setEnabled(True)
# info banners needed
if section is None or section == "banners":
self._clear_info_banners()
if self.STATE.vorgang_id is None:
self._add_info_banner(
text="⚠️ Dieser Eintrag ist noch nicht gespeichert.",
button_text="",
)
else:
if self.STATE.un_id is None and self.STATE.pers_id is None:
self._add_info_banner(
text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.",
button_text="Jetzt verknüpfen",
button_func=self._open_linking_dialogue,
)
elif (
self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL
and self.STATE.pers_id is None
):
self._add_info_banner(
text=(
"⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu "
"einer Person fehlt."
),
button_text="Jetzt verknüpfen",
button_func=self._open_linking_dialogue,
)
# child modules
# call to child modules' syncing method not needed because these are initialised by
# their own "load_state" method which again calls the modules sync method
if section is None or section == "table":
table_state = Page_Consulting_Table_State(
session=self.STATE.session,
row_states=self.STATE.cons_sessions,
locked=self.STATE.locked,
)
self.table_sessions.load_state(table_state)
if section is None or section == "locking":
if self.STATE.locked:
self.table_sessions.lock()
self.edit_buttons.set_modify_state(False, set_delete_btn=True)
else:
self.table_sessions.unlock()
self.edit_buttons.set_modify_state(True, set_delete_btn=False)
self._activate_delete()
def _sync_GUI_to_state(self) -> None:
# call to child modules' syncing method not needed because their syncing method is
# called when the "get_state" method is called --> this has to be managed within
# this method
table_state = self.table_sessions.get_state()
self.STATE.cons_sessions = table_state.row_states
self._activate_delete()
def validate(self) -> list[str]:
errors: list[str] = []
errors.extend(self.table_sessions.validate())
return errors
def load_state(
self,
new_state: Page_Consulting_State,
) -> None:
logger_page_consulting.debug("[Consulting] Loading with request:\n%s", new_state)
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI(None)
def _load_database(
self,
cons_id: ConsId | None,
) -> None:
# load data model from database
logger_page_consulting.info("[Consulting Page] Load data method...")
logger_page_consulting.debug("[Consulting Page] Lookup ID: %s", cons_id)
self._reset()
if cons_id is None:
logger_page_consulting.debug(
"[Consulting Page] Loading: Lookup ID NULL. Do nothing!"
)
return
res = backend.page_consulting_from_db(cons_id)
if res.status != STATUS_HANDLER.SUCCESS:
logger_page_consulting.error(
(
"[Consulting Page] There were errors during the database "
"loading procedure."
),
stack_info=True,
)
exc_formatted = (
f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}"
)
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Laden fehlgeschlagen",
(
"Beim Laden der Daten ist ein Fehler aufgetreten. Details sind "
"unten angefügt."
),
detailed_text=exc_formatted,
)
msg_box.exec()
return
# instantiate state from data model
consulting_process = res.unwrap()
self._load_from_pydantic(consulting_process, sync_to_GUI=False)
def _load_from_pydantic(
self,
data: Beratungsgespraech_Vorgang,
update_only: bool = False,
sync_to_GUI: bool = True,
) -> None:
self.STATE.vorgang_id = data.vorgang_id
self.STATE.beratungs_typ = ConsultingType(data.beratungs_typ)
self.STATE.un_id = data.un_id
self.STATE.pers_id = data.pers_id
self.STATE.titel = data.titel
self.STATE.geloescht = data.geloescht is not None
self.STATE.locked = True
if update_only:
relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht]
rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None]
assert len(relevant_gui_states) == len(rel_cons_session_states)
for gui_state, pydantic_state in zip(
relevant_gui_states, rel_cons_session_states
):
gui_state.beratung_id = pydantic_state.beratung_id
else:
self.STATE.cons_sessions.clear()
for pydantic_state in data.beratungen:
# Deleted sessions are not part of the retrieved data from the backend if this
# page is initialised from a fresh backend call to get data. (Updates do
# indeed have data which must be filtered -- see above)
cons_session_state = Page_Consulting_ConsultingSession_State(
session=self.STATE.session,
nutzer_id=pydantic_state.nutzer_id,
nutzer_name=pydantic_state.nutzer_name,
beratung_id=pydantic_state.beratung_id,
zeitstempel=pydantic_state.zeitstempel,
ansprechpartner=pydantic_state.ansprechpartner,
kommunikationsweg=pydantic_state.kommunikationsweg,
thema_crm_matrix=pydantic_state.thema_crm_matrix,
anmerkungen=pydantic_state.anmerkungen,
rueckmeldung=pydantic_state.rueckmeldung,
locked=self.STATE.locked,
)
self.STATE.cons_sessions.append(cons_session_state)
if sync_to_GUI:
self._sync_state_to_GUI(None)
def get_state(self) -> Page_Consulting_State:
self._sync_GUI_to_state()
return self.STATE
def _delete_entry(self) -> None:
assert self.STATE.vorgang_id is not None and self.STATE.geloescht
logger_page_consulting.debug(
"[Consulting Page] This was saved in the database "
"and is marked for deletion - fast path."
)
res = backend.page_consulting_delete(self.STATE.vorgang_id)
if res.status != STATUS_HANDLER.SUCCESS:
logger_page_consulting.error(
(
"[Consulting Page] There were errors during the database "
"deletion procedure."
),
stack_info=True,
)
exc_formatted = (
f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}"
)
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Löschen fehlgeschlagen",
(
"Beim Löschen der Daten ist ein Fehler aufgetreten. Details sind "
"unten angefügt."
),
detailed_text=exc_formatted,
)
msg_box.exec()
return
logger_page_consulting.info("[Consulting Page] Data deleted successfully")
msg_box = get_message_box(
QMessageBox.Icon.Information,
"Löschen erfolgreich",
"Die Daten wurden erfolgreich gelöscht",
)
msg_box.exec()
self._sync_state_to_GUI(None)
def save_data(self) -> None:
logger_page_consulting.debug("[Consulting Page] Call saving data...")
if self.STATE.vorgang_id is None and self.STATE.geloescht:
logger_page_consulting.debug(
"[Consulting Page] This was never saved in the database "
"and is marked for deletion - ignore."
)
return
self._disable_save()
errors = self.validate()
if errors:
logger_page_consulting.error(
"[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors)
)
GUI_validation_error_handling(errors)
self._enable_save()
return
logger_page_consulting.info("[Consulting Page] Success! All data is valid")
logger_page_consulting.info("[Consulting Page] Get form data call...")
self._sync_GUI_to_state()
logger_page_consulting.debug("[Consulting Page] Current state:\n%s", self.STATE)
try:
logger_page_consulting.debug("[Consulting Page] Call Pydantic...")
pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = []
timestamp_deleted: datetime.datetime | None = None
if self.STATE.geloescht:
timestamp_deleted = datetime.datetime.now(datetime.UTC)
for row in self.STATE.cons_sessions:
logger_page_consulting.debug("[Consulting Page] Row state:\n%s", row)
if row.beratung_id is None and row.ist_geloescht:
logger_page_consulting.debug(
(
"[Consulting Page] Session which was never saved in the database "
"and is marked for deletion - ignore."
)
)
continue
timestamp_deleted_session: datetime.datetime | None = None
if timestamp_deleted is not None:
# if process is deleted, all sessions must be marked as deleted
timestamp_deleted_session = timestamp_deleted
elif row.ist_geloescht:
timestamp_deleted_session = datetime.datetime.now(datetime.UTC)
cons_session_dict = dc.asdict(row)
cons_session = Beratungsgespraech_Einzelgespraech(
vorgang_id=self.STATE.vorgang_id,
geloescht=timestamp_deleted_session,
**cons_session_dict,
)
pydantic_sessions.append(cons_session)
consulting_process = Beratungsgespraech_Vorgang(
vorgang_id=self.STATE.vorgang_id,
un_id=self.STATE.un_id,
pers_id=self.STATE.pers_id,
titel=self.STATE.titel,
beratungs_typ=self.STATE.beratungs_typ, # type: ignore
geloescht=timestamp_deleted,
beratungen=pydantic_sessions,
)
except ValidationError as e:
logger_page_consulting.error(
"[Consulting Page] Error during Pydantic validation phase:\n%s",
pformat(e.errors()),
)
pydantic_validation_error_handling(e)
else:
if DEBUG_NO_DATABASE:
return
logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.")
logger_page_consulting.debug(
"[Consulting Page] Pydantic model:\n%s", consulting_process
)
res = backend.page_consulting_to_db(consulting_process)
if res.status != STATUS_HANDLER.SUCCESS:
logger_page_consulting.error(
(
"[Consulting Page] There were errors during the database "
"saving/updating procedure."
),
stack_info=True,
)
exc_formatted = (
f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}"
)
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Speichern fehlgeschlagen",
(
"Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind "
"unten angefügt."
),
detailed_text=exc_formatted,
)
msg_box.exec()
return
updated_consulting_process = res.unwrap()
logger_page_consulting.info(
"[Consulting Page] Load state from modified Pydantic model"
)
self._load_from_pydantic(updated_consulting_process, update_only=True)
self.update_triggered.emit()
logger_page_consulting.info("[Consulting Page] Data saved successfully")
msg_box = get_message_box(
QMessageBox.Icon.Information,
"Speichern erfolgreich",
"Die Daten wurden erfolgreich gespeichert",
)
msg_box.exec()
# self._activate_delete()
finally:
# always re-enable save, even if error occurred
self._enable_save()
def lock(self) -> None:
self.STATE.locked = True
self._sync_state_to_GUI("locking")
def unlock(self) -> None:
self.STATE.locked = False
self._sync_state_to_GUI("locking")
class Page_Consulting_LinkingDialog(QDialog):
def __init__(
self,
state: Page_Consulting_Linking_State,
parent: QWidget | None = None,
):
super().__init__(parent)
self.STATE = state
self.setWindowTitle("Beziehung verknüpfen")
self.resize(700, 450)
# firmen_aus_db = [{"id": 1, "name": "Siemens AG"}, {"id": 2, "name": "Bosch GmbH"}]
# personen_aus_db = [
# {
# "id": 50,
# "name": "Max Mustermann",
# "unternehmen_id": 1,
# "firma_name": "Siemens AG",
# },
# {
# "id": 51,
# "name": "Erika Muster",
# "unternehmen_id": 1,
# "firma_name": "Siemens AG",
# },
# {
# "id": 52,
# "name": "Peter Müller",
# "unternehmen_id": 2,
# "firma_name": "Bosch GmbH",
# },
# {
# "id": 53,
# "name": "Freiberufler Frank",
# "unternehmen_id": None,
# "firma_name": None,
# },
# ]
# self.all_unternehmen = firmen_aus_db
# self.all_personen = personen_aus_db
# # Rückgabewerte
# self.gewaehltes_unternehmen = None
# self.gewaehlte_person = None
# --- UI LAYOUT ---
main_layout = QVBoxLayout(self)
column_layout = QHBoxLayout()
# left column: companies
self.vbox_companies = QVBoxLayout()
self.vbox_companies.addWidget(QLabel("Unternehmen wählen (Optional):"))
self.btn_clear_company = QPushButton("Auswahl aufheben")
self.list_companies = QListWidget()
self.vbox_companies.addWidget(self.list_companies)
self.vbox_companies.addWidget(self.btn_clear_company)
# right column: persons
self.vbox_persons = QVBoxLayout()
self.vbox_persons.addWidget(QLabel("Person wählen (Optional):"))
self.btn_clear_person = QPushButton("Auswahl aufheben")
self.list_persons = QListWidget()
self.vbox_persons.addWidget(self.list_persons)
self.vbox_persons.addWidget(self.btn_clear_person)
column_layout.addLayout(self.vbox_companies)
column_layout.addLayout(self.vbox_persons)
main_layout.addLayout(column_layout)
# Unten: OK / Abbrechen Buttons
self.button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
self.ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
self.ok_btn.setText("Verknüpfung speichern")
self.ok_btn.setEnabled(False)
self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen")
main_layout.addWidget(self.button_box)
# initial loading
# self._load_companies()
# self._load_persons()
# list_style = """
# QListWidget::item:selected {
# background-color: #0078D4; /* Blau, wenn die Liste aktiv fokussiert ist */
# color: white;
# }
# QListWidget::item:selected:!active {
# background-color: #CCE4F7; /* Gut sichtbares Hellblau, wenn die Liste den Fokus verliert */
# color: black;
# }
# QListWidget::item:hover {
# background-color: #F0F0F0; /* Dezentes Grau beim Drüberfahren */
# }
# """
# self.list_companies.setStyleSheet(list_style)
# self.list_persons.setStyleSheet(list_style)
# signals
self.list_companies.itemSelectionChanged.connect(self._company_selected)
self.list_persons.itemSelectionChanged.connect(self._person_selected)
self.btn_clear_company.clicked.connect(self._clear_company_selection)
self.btn_clear_person.clicked.connect(self._clear_person_selection)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self._sync_state_to_GUI(reset=True)
def _enable_person_selection(
self,
enable: bool,
) -> None:
logger_page_consulting.debug(">>>>>>> CALL ENABLE: %s", enable)
for i in range(self.vbox_persons.count()):
item = self.vbox_persons.itemAt(i)
if not item:
continue
widget = item.widget()
logger_page_consulting.debug(">>>>>>> Type widget: %s", type(widget))
if isinstance(widget, QWidget):
logger_page_consulting.debug(">>>>>>> Widget (enable=%s): %s", enable, widget)
widget.setEnabled(enable)
def _reset(self) -> None:
self._clear_company_selection()
self._clear_person_selection()
def _sync_state_to_GUI(
self,
reset: bool,
section: Literal["person_selection", "validation"] | None = None,
) -> None:
if reset:
self._reset()
self._load_companies()
if section is None or section == "person_selection":
if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL:
self._enable_person_selection(False)
elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL:
self._enable_person_selection(True)
self.list_persons.blockSignals(True)
self._load_persons(self.STATE.un_id)
self.list_persons.blockSignals(False)
if section is None or section == "validation":
self._validate()
def _validate(self) -> None:
valid: bool = False
if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL:
assert self.STATE.pers_id is None
if self.STATE.un_id is not None:
valid = True
elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL:
if self.STATE.pers_id is not None:
valid = True
else:
RuntimeError("Undefined consulting type")
if valid:
self.ok_btn.setEnabled(True)
else:
self.ok_btn.setEnabled(False)
def _load_companies(self) -> None:
self.list_companies.clear()
companies = backend.page_consulting_linking_companies(un_id=self.STATE.un_id)
logger_page_consulting.debug(
"[Page Consulting -- Linking] Retrieved company entries:\n%s", companies
)
for company in companies:
item = QListWidgetItem(company.display_name)
item.setData(Qt.ItemDataRole.UserRole, company)
self.list_companies.addItem(item)
def _load_persons(
self,
un_id: RecId | None,
) -> None:
"""Lädt Personen. Wenn firma_id übergeben wird, wird gefiltert."""
self.list_persons.clear()
persons = backend.page_consulting_linking_persons(un_id)
logger_page_consulting.debug(
"[Page Consulting -- Linking] Retrieved person entries:\n%s", persons
)
for person in persons:
item = QListWidgetItem(person.display_name)
item.setData(Qt.ItemDataRole.UserRole, person)
self.list_persons.addItem(item)
# --- LOGIK ---
def _company_selected(self) -> None:
items = self.list_companies.selectedItems()
if not items:
return
company = cast(
"LinkingConsultationsEntryCompany", items[0].data(Qt.ItemDataRole.UserRole)
)
self.STATE.un_id = company.un_id
logger_page_consulting.debug(
"[Page Consulting -- Linking] Company selected. State is:\n%s",
pformat(self.STATE),
)
self._sync_state_to_GUI(reset=False)
# if self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL:
# self.list_persons.blockSignals(True)
# self._load_persons(un_id=self.STATE.un_id)
# self.list_persons.blockSignals(False)
def _person_selected(self) -> None:
items = self.list_persons.selectedItems()
if not items:
return
person = cast(
"LinkingConsultationsEntryPerson", items[0].data(Qt.ItemDataRole.UserRole)
)
self.STATE.pers_id = person.pers_id
self._sync_state_to_GUI(reset=False, section="validation")
# TODO this backlink is a really good idea and should be implemented
# if person["unternehmen_id"]:
# self.list_companies.blockSignals(True)
#
# for i in range(self.list_companies.count()):
# item = self.list_companies.item(i)
# firma_data = item.data(Qt.ItemDataRole.UserRole)
# if firma_data["id"] == person["unternehmen_id"]:
# item.setSelected(True)
# self.gewaehltes_unternehmen = firma_data
# break
# self.list_companies.blockSignals(False)
def _clear_company_selection(self) -> None:
self.list_companies.clearSelection()
self.STATE.un_id = None
self._sync_state_to_GUI(reset=False)
def _clear_person_selection(self) -> None:
self.list_persons.clearSelection()
self.STATE.pers_id = None
self._sync_state_to_GUI(reset=False)
def get_state(self) -> Page_Consulting_Linking_State:
return self.STATE
class Page_Consulting_ConsultingSession(QWidget):
"""child of 'Page_Consulting_DynamicTable'"""
delete_request = Signal(QWidget)
def __init__(
self,
state: Page_Consulting_ConsultingSession_State,
) -> None:
super().__init__()
assert isinstance(state, Page_Consulting_ConsultingSession_State)
self.STATE = state
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 2, 0, 2) # Sehr kompakt
layout.setSpacing(10)
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
ROW_HEIGHT = 70
self.widget_registry: WidgetRegistry = {}
user_field_def = FormField[QLineEdit](
"Nutzername",
FormFieldType.TEXT,
readonly=True,
placeholder="wird über aktiven Nutzer bei Anlage gesetzt",
enable_uuid_key=True,
)
_, self.user_field = build_and_add_widget(user_field_def, layout)
# ?? date or datetime?
timestamp_def = FormField[FlexibleDateTimeInput](
"Zeitstempel",
FormFieldType.DATETIME_FLEXIBLE,
required=True,
key="zeitstempel",
)
_, self.timestamp = build_and_add_widget(
timestamp_def,
layout,
widget_registry=self.widget_registry,
)
self.timestamp.set_pydatetime(datetime.datetime.now(TIMEZONE_CEST))
contact_def = FormField[QLineEdit](
"Kontaktperson",
FormFieldType.TEXT,
placeholder="Freitext",
required=True,
key="ansprechpartner",
)
_, self.contact = build_and_add_widget(
contact_def,
layout,
widget_registry=self.widget_registry,
)
contact_type_def = FormField[QComboBox](
"Kontakttyp",
FormFieldType.DROPDOWN,
options=[("Persönlich", None), ("Telefon", None), ("Mail", None)],
required=True,
key="kommunikationsweg",
)
_, self.contact_type = build_and_add_widget(
contact_type_def,
layout,
widget_registry=self.widget_registry,
)
crm_link_def = FormField[QLineEdit](
"CRM-Verknüpfung",
FormFieldType.TEXT,
placeholder="CRM Links",
required=True,
key="thema_crm_matrix",
)
_, self.crm_link = build_and_add_widget(
crm_link_def,
layout,
widget_registry=self.widget_registry,
)
plain_text_def = FormField[QPlainTextEdit](
"Anmerkungen",
FormFieldType.LONGTEXT,
key="anmerkungen",
)
_, self.plain_text = build_and_add_widget(
plain_text_def,
layout,
widget_registry=self.widget_registry,
)
response_def = FormField[QPlainTextEdit](
"Rückmeldung",
FormFieldType.LONGTEXT,
key="rueckmeldung",
)
_, self.response = build_and_add_widget(
response_def,
layout,
widget_registry=self.widget_registry,
)
self.btn_delete = QPushButton("✕")
self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen
self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;")
self.btn_delete.clicked.connect(self._delete_request)
self.btn_delete.setFixedWidth(30)
layout.addWidget(self.btn_delete)
widgets = (
self.user_field,
self.timestamp,
self.contact,
self.contact_type,
self.crm_link,
self.plain_text,
self.response,
self.btn_delete,
)
for widget in widgets:
widget.setFixedHeight(ROW_HEIGHT)
# layout.addWidget(widget)
# column stretching: must be matched with header
num_widgets = len(widgets)
for i, widget in enumerate(widgets):
stretch: int
if i == (num_widgets - 1):
stretch = 0
else:
if "plaintext" in widget.__class__.__name__.lower():
stretch = 2
else:
stretch = 1
layout.setStretch(i, stretch)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self._sync_state_to_GUI(None)
def _delete_request(self) -> None:
confirm = get_user_confirmation(
self,
"Löschen bestätigen",
"Soll der Eintrag wirklich gelöscht werden?",
)
if confirm:
self.STATE.ist_geloescht = True
self.delete_request.emit(self)
def _sync_state_to_GUI(
self,
section: Literal["values", "locking"] | None,
) -> None:
logger_page_consulting.debug(
"[Page Consulting -- Consulting Session]: State = %s", self.STATE
)
if section is None or section == "values":
set_widget_value(self.user_field, self.STATE.nutzer_name)
if self.STATE.zeitstempel is not None:
set_widget_value(
self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST)
)
set_widget_value(self.contact, self.STATE.ansprechpartner)
set_widget_value(self.contact_type, self.STATE.kommunikationsweg)
set_widget_value(self.crm_link, self.STATE.thema_crm_matrix)
set_widget_value(self.plain_text, self.STATE.anmerkungen)
set_widget_value(self.response, self.STATE.rueckmeldung)
if section is None or section == "locking":
if self.STATE.locked:
lock_form(self.widget_registry)
self.btn_delete.setEnabled(False)
else:
unlock_form(self.widget_registry)
self.btn_delete.setEnabled(False)
def _sync_GUI_to_state(self) -> None:
self.STATE.zeitstempel = self.timestamp.get_pydatetime()
self.STATE.ansprechpartner = get_widget_value(self.contact)
self.STATE.kommunikationsweg = get_widget_value(self.contact_type)
self.STATE.thema_crm_matrix = get_widget_value(
self.crm_link
) # TODO: change to correct link
self.STATE.anmerkungen = get_widget_value(self.plain_text)
self.STATE.rueckmeldung = get_widget_value(self.response)
def get_state(self) -> Page_Consulting_ConsultingSession_State:
self._sync_GUI_to_state()
return self.STATE
def validate(self) -> list[str]:
logger_page_consulting.debug(
">>>>> Call validate on Session. Widget registry:\n%s",
pformat_registry(self.widget_registry),
)
return validate_form_data(self.widget_registry)
def save_data(self) -> None:
pass
def load_state(
self,
new_state: Page_Consulting_ConsultingSession_State,
) -> None:
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI(None)
def lock(self) -> None:
self.STATE.locked = True
self._sync_state_to_GUI("locking")
def unlock(self) -> None:
self.STATE.locked = False
self._sync_state_to_GUI("locking")
class Page_Consulting_Table(QWidget):
"""child of 'Page_Consulting'"""
def __init__(
self,
state: Page_Consulting_Table_State,
) -> None:
super().__init__()
self.STATE = state
self.setWindowTitle("Native Dynamische Liste")
self.resize(600, 400)
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 15, 0, 15)
main_layout.setSpacing(10)
# --- BUTTON (ADD) ---
self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen")
self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_add.setProperty("styleClass", "addButton")
main_layout.addWidget(self.btn_add)
self.btn_add.clicked.connect(lambda: self.add_row(None, False))
# --- HEADER ---
header_widget = QWidget()
header_layout = QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 5, 0, 5)
header_layout.setSpacing(10)
lbl_user_field = QLabel("Wer")
lbl_timestamp = QLabel("Wann")
lbl_contact = QLabel("Mit wem")
lbl_contact_type = QLabel("Wie")
lbl_crm_link = QLabel("Was")
lbl_plain_text = QLabel("Anmerkungen")
lbl_response = QLabel("Rückmeldung")
lbl_action_delete = QLabel("")
lbl_action_delete.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_action_delete.setFixedWidth(30)
labels = (
lbl_user_field,
lbl_timestamp,
lbl_contact,
lbl_contact_type,
lbl_crm_link,
lbl_plain_text,
lbl_response,
lbl_action_delete,
)
header_font = QFont()
header_font.setBold(True)
for lbl in labels:
lbl.setFont(header_font)
lbl.setStyleSheet("color: #666666;")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
header_layout.addWidget(lbl)
plaintext_labels = {lbl_plain_text, lbl_response}
num_labels = len(labels)
for i, widget in enumerate(labels):
stretch: int
if i == (num_labels - 1):
stretch = 0
else:
if widget in plaintext_labels:
stretch = 2
else:
stretch = 1
header_layout.setStretch(i, stretch)
main_layout.addWidget(header_widget)
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
main_layout.addWidget(separator)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
self.container_widget = QWidget()
self.container_layout = QVBoxLayout(self.container_widget)
self.container_layout.setContentsMargins(0, 5, 0, 5)
self.container_layout.setSpacing(6)
self.container_layout.addStretch()
self.scroll_area.setWidget(self.container_widget)
main_layout.addWidget(self.scroll_area)
# --- STYLING (QSS) ---
# self.setStyleSheet("""
# QWidget {
# background-color: #1e1e24;
# color: #e0e0e6;
# font-family: 'Segoe UI', sans-serif;
# font-size: 13px;
# }
# #HeaderWidget QLabel {
# font-weight: bold;
# color: #888892;
# font-size: 11px;
# letter-spacing: 1px;
# }
# QLineEdit {
# background-color: #2a2a32;
# border: 1px solid #3f3f4a;
# border-radius: 6px;
# padding: 6px 10px;
# color: white;
# }
# QLineEdit:focus {
# border: 1px solid #007acc;
# }
# QPushButton {
# background-color: #007acc;
# color: white;
# border: none;
# border-radius: 6px;
# padding: 8px 15px;
# font-weight: bold;
# }
# QPushButton:hover {
# background-color: #0098ff;
# }
# #DeleteButton {
# background-color: transparent;
# color: #ff5555;
# border: 1px solid #3f3f4a;
# font-size: 14px;
# padding: 4px;
# }
# #DeleteButton:hover {
# background-color: #ff3333;
# color: white;
# border-color: #ff3333;
# }
# QScrollArea {
# background-color: transparent;
# }
# """)
def add_row(
self,
row_state: Page_Consulting_ConsultingSession_State | None,
state_exists: bool,
) -> None:
if row_state is None:
row_state = Page_Consulting_ConsultingSession_State(
session=self.STATE.session,
beratung_id=None,
nutzer_id=self.STATE.session.user_id,
nutzer_name=self.STATE.session.user_name,
locked=False,
)
row = Page_Consulting_ConsultingSession(state=row_state)
row.delete_request.connect(self.remove_row)
index = self.container_layout.count() - 1
self.container_layout.insertWidget(index, row)
self.STATE.child_modules.append(row)
if not state_exists:
self.STATE.row_states.append(row.get_state())
def remove_row(
self,
table_row_widget: Page_Consulting_ConsultingSession,
) -> None:
self.container_layout.removeWidget(table_row_widget)
self.STATE.child_modules.remove(table_row_widget)
# do not remove the corresponding state since it is tracked
# to be post-processed when the database saving operation is triggered
table_row_widget.deleteLater()
def remove_all_rows(self) -> None:
while self.STATE.child_modules:
self.remove_row(self.STATE.child_modules[0])
def _sync_state_to_GUI(
self,
section: Literal["rows", "locking"] | None,
) -> None:
self.setUpdatesEnabled(False)
if section is None or section == "rows":
self.remove_all_rows()
# states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht]
# for row_state in states_to_show:
for row_state in self.STATE.row_states:
if not row_state.ist_geloescht:
self.add_row(row_state, True)
if section is None or section == "locking":
if self.STATE.locked:
self.btn_add.setEnabled(False)
for m in self.STATE.child_modules:
m.lock()
else:
self.btn_add.setEnabled(True)
for m in self.STATE.child_modules:
m.unlock()
self.setUpdatesEnabled(True)
def _sync_GUI_to_state(self) -> None:
logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state")
logger_page_consulting.debug(
(
"[Page -- Consulting] Table: Row data is managed when entries are added. "
"There is no action needed. All states are obtained by reference and set "
"by their row components/widgets. Therefore, this method calls the "
"method `get_state` of all ACTIVE components to update their respective "
"states with the current user inputs."
)
)
logger_page_consulting.debug(
"[Page -- Consulting] Table - Child modules: %s",
pformat(self.STATE.child_modules),
)
# Child modules are ACTIVE elements, viewable and changeable by the user.
# The states are already part of this widget and linked by reference, but
# the current user input must be synced with these states. Therefore, the
# `get_state` method of these children is called, which implicitly updates
# their state. The returned state reference is not needed.
for row in self.STATE.child_modules:
_ = row.get_state()
def get_state(self) -> Page_Consulting_Table_State:
logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...")
self._sync_GUI_to_state()
logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE)
return self.STATE
def load_state(
self,
new_state: Page_Consulting_Table_State,
) -> None:
logger_page_consulting.debug(
"[Page -- Consulting] Table: Call loading from state, %s", new_state
)
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI(None)
def validate(self) -> list[str]:
errors: list[str] = []
for m in self.STATE.child_modules:
errors.extend(m.validate())
return errors
def save_data(self) -> None:
pass
def lock(self) -> None:
self.STATE.locked = True
self._sync_state_to_GUI("locking")
def unlock(self) -> None:
self.STATE.locked = False
self._sync_state_to_GUI("locking")
class InfoBanner(QFrame):
btn_clicked = Signal()
def __init__(
self,
text: str,
button_text: str,
parent: QWidget | None = None,
):
super().__init__(parent)
self.setStyleSheet("""
InfoBanner {
background-color: #FFF3CD;
border: 1px solid #FFEBAA;
border-radius: 6px;
}
QLabel {
color: #856404;
font-size: 13px;
}
QPushButton {
background-color: #856404;
color: white;
border-radius: 4px;
padding: 4px 10px;
font-weight: bold;
}
QPushButton:hover {
background-color: #6C5204;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(15, 10, 15, 10)
self.label = QLabel(text)
layout.addWidget(self.label)
layout.addStretch()
if button_text:
self.button = QPushButton(button_text)
layout.addWidget(self.button)
self.button.clicked.connect(self.btn_clicked.emit)
# // test components
####################################################################
def clear_layout(
layout: QLayout | None,
) -> None:
if layout is None:
return
while layout.count():
child = layout.takeAt(0)
if child is None:
continue
widget = child.widget()
if widget is not None:
widget.deleteLater()
elif child.layout():
clear_layout(child.layout())
def get_message_box(
msg_type: QMessageBox.Icon,
title: str,
message: str,
detailed_text: str | None = None,
) -> QMessageBox:
msg_box = QMessageBox()
msg_box.setIcon(msg_type)
msg_box.setWindowTitle(title)
msg_box.setText(message)
if detailed_text is not None:
msg_box.setDetailedText(detailed_text)
return msg_box
def get_user_confirmation(
parent_widget: QWidget,
title: str,
message: str,
) -> bool:
response = QMessageBox.question(
parent_widget,
title,
message,
buttons=(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No),
defaultButton=QMessageBox.StandardButton.No,
)
if response == QMessageBox.StandardButton.Yes:
return True
else:
return False
def pydantic_validation_error_handling(
val_error: ValidationError,
) -> None:
error_texts: list[str] = []
translated_errors = translate_pydantic_errors(val_error.errors())
for error in translated_errors:
error_field = str(error["loc"][0])
reason = error["msg"]
path = " → ".join(error["loc"][:-1]) # type: ignore
error_texts.append(f"- {error_field}: {reason}, (Pfad: {path})")
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Fehler bei der Validierung der Eingabedaten",
(
"Bei der Validierung der Eingaben ist ein Fehler aufgetreten. Details "
"sind unten angefügt."
),
"\n".join(error_texts),
)
msg_box.exec()
def GUI_validation_error_handling(
errors: list[str],
) -> None:
error_text = "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join(
errors
)
msg_box = get_message_box(
QMessageBox.Icon.Warning,
"Fehlende oder fehlerhafte Angaben",
error_text,
)
msg_box.exec()
class _ModifyButton(QPushButton):
modify_activated = Signal()
modify_deactivated = Signal()
def __init__(
self,
external_state_management: bool,
txt_activate: str = "Bearbeitung aktivieren (Strg + B)",
txt_deactivate: str = "Bearbeitung deaktivieren (Strg + B)",
shortcut: str = "Ctrl+B",
parent: QWidget | None = None,
) -> None:
super().__init__(txt_activate, parent=parent)
self.txt_activate = txt_activate
self.txt_deactivate = txt_deactivate
self.custom_shortcut: (
QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int
) = shortcut
self.is_modify_state = False
self.external_state_management = external_state_management
self.setShortcut(self.custom_shortcut)
self.clicked.connect(self._clicked)
def set_modify_state(
self,
enable_modification: bool,
) -> None:
if enable_modification:
self.setText(self.txt_deactivate)
else:
self.setText(self.txt_activate)
self.setShortcut(self.custom_shortcut)
self.is_modify_state = enable_modification
def _clicked(self) -> None:
if self.is_modify_state:
self.setText(self.txt_activate)
self.modify_deactivated.emit()
else:
self.setText(self.txt_deactivate)
self.modify_activated.emit()
self.setShortcut(self.custom_shortcut)
if not self.external_state_management:
self.is_modify_state = not self.is_modify_state
@override
def setEnabled(
self,
enabled: bool,
) -> None:
super().setEnabled(enabled)
if enabled:
self.setShortcut(self.custom_shortcut)
@override
def setShortcut(
self,
key: QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int,
) -> None:
self.custom_shortcut = key
super().setShortcut(key)
class EditButtons(QWidget):
def __init__(
self,
add_modify_btn: bool,
add_reset_btn: bool,
) -> None:
super().__init__()
layout_btn = QHBoxLayout(self)
layout_btn.setContentsMargins(0, 0, 0, 0)
# modify
self.modify_button: _ModifyButton | None = None
if add_modify_btn:
self.modify_button = _ModifyButton(external_state_management=True)
self.modify_button.setFixedHeight(50)
self.modify_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
layout_btn.addWidget(self.modify_button)
# save
self.save_btn_txt_enabled = "Speichern (Strg + S)"
self.save_btn_txt_disabled = "Wird gespeichert..."
self.save_btn = QPushButton(self.save_btn_txt_enabled)
self.save_btn.setShortcut("Ctrl+S")
self.save_btn.setFixedHeight(50)
self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout_btn.addWidget(self.save_btn)
# reset
self.reset_button: QPushButton | None = None
if add_reset_btn:
self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)")
self.reset_btn.setShortcut("Ctrl+Z")
self.reset_btn.setFixedHeight(50)
self.reset_btn.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
layout_btn.addWidget(self.reset_btn)
# delete
self.delete_btn = QPushButton("Eintrag löschen (Strg + L)")
self.delete_btn.setShortcut("Ctrl+L")
self.delete_btn.setFixedHeight(50)
self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout_btn.addWidget(self.delete_btn)
def set_modify_state(
self,
enable_modification: bool,
set_delete_btn: bool = True,
) -> None:
if self.modify_button is not None:
self.modify_button.set_modify_state(enable_modification)
self.save_btn.setEnabled(enable_modification)
if set_delete_btn:
self.delete_btn.setEnabled(enable_modification)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# STATE MANAGEMENT
session = Session(user_id=42, user_name="TEST-USER")
self.STATE = Page_MainPage_State(session=session)
self._history_stack: list[int] = []
self.setWindowTitle("Master")
self.resize(1800, 1000)
# MENU
self.create_menu()
# STACK: stack to change between 'sites'
self.stack = QStackedWidget()
self.setCentralWidget(self.stack)
# MAIN PAGE: table view
self.main_page = self.setup_main_page()
self.stack.addWidget(self.main_page)
# SITE: add new entries for 'Grunderfassung'
new_initrec_select_state = Page_NewEntry_State(session=self.STATE.session)
self.new_entry_select = Page_NewEntry(new_initrec_select_state)
self.new_entry_select.back_requested.connect(self.show_main_page)
# self.new_initrec_select.company_requested.connect(self.show_page_initrec_company)
# self.new_initrec_select.person_requested.connect(self.show_page_initrec_person)
self.new_entry_select.consulting_requested.connect(self.show_page_consulting)
self.new_entry_select.new_initrec_requested.connect(self.show_page_newinitrec)
self.stack.addWidget(self.new_entry_select)
# SITE: 'Grunderfassung'
newinitrec_state = Page_InitRec_State(
session=session,
ids=EntityIds(),
recording_type=RecordingType.WITH_COMPANY,
form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}),
locked=False,
)
self.newinitrec = Page_InitRec(newinitrec_state)
self.newinitrec.back_main_requested.connect(self.show_main_page)
self.newinitrec.back_requested.connect(self.simple_go_back)
# self.newinitrec.update_main_page.connect(self.update_grid)
self.stack.addWidget(self.newinitrec)
# SITE: 'Grunderfassung Unternehmen'
# ids = EntityIds(type=RecordingType.COMPANY)
# initrec_company_state = Page_InitRecCompany_State(
# session=self.STATE.session, ids=ids, locked=False
# )
# self.initrec_company = Page_InitRecCompany(initrec_company_state)
# self.initrec_company.back_main_requested.connect(self.show_main_page)
# self.initrec_company.back_requested.connect(self.simple_go_back)
# self.initrec_company.update_main_page.connect(self.update_grid)
# self.initrec_company.initrec_person_requested.connect(self.show_page_initrec_person)
# self.initrec_company.company_profile_requested.connect(self.show_page_company_profile)
# self.stack.addWidget(self.initrec_company)
# SITE: 'Grunderfassung Person'
# ids = EntityIds(type=RecordingType.PERSON)
# initrec_person_state = Page_InitRecPerson_State(
# session=self.STATE.session, ids=ids, locked=False
# )
# self.initrec_person = Page_InitRecPerson(initrec_person_state)
# self.initrec_person.back_main_requested.connect(self.show_main_page)
# self.initrec_person.back_requested.connect(self.simple_go_back)
# self.initrec_person.update_triggered.connect(self.update_grid)
# self.stack.addWidget(self.initrec_person)
# SITE: 'Unternehmensprofil'
comp_profile_state = Page_CompanyProfile_State(
session=self.STATE.session, ids=EntityIds()
)
self.company_profile = Page_CompanyProfile(comp_profile_state)
self.company_profile.back_main_requested.connect(self.show_main_page)
# TODO remove
# self.company_profile.back_requested.connect(self.show_page_initrec_company)
self.company_profile.back_requested.connect(self.simple_go_back)
self.company_profile.consulting_requested.connect(self.show_page_consulting)
self.stack.addWidget(self.company_profile)
# SITE: 'Beratungsgespräch'
self.consulting_page = Page_Consulting(self.STATE.session)
self.consulting_page.back_main_requested.connect(self.show_main_page)
self.consulting_page.back_requested.connect(self.simple_go_back)
self.consulting_page.update_triggered.connect(self.update_page_company_profile)
self.stack.addWidget(self.consulting_page)
def setup_main_page(self):
# QMainWindow defines frame --> container widget needed for the middle
main_widget = QWidget()
outer_layout = QHBoxLayout(main_widget)
vert_layout = QVBoxLayout()
# add buttons
new_btn = QPushButton("Neu →")
new_btn.setFixedWidth(100)
new_btn.setFixedHeight(40)
new_btn.clicked.connect(self.show_new_entry_select)
vert_layout.addWidget(new_btn)
if DEBUG:
# TODO reactivate
# update_btn = QPushButton("UPDATE")
# update_btn.setFixedWidth(100)
# update_btn.setFixedHeight(40)
# update_btn.clicked.connect(self.update_grid)
# clear_btn = QPushButton("CLEAR")
# clear_btn.setFixedWidth(100)
# clear_btn.setFixedHeight(40)
# clear_btn.clicked.connect(self._clear_layout)
# vert_layout.addWidget(update_btn)
# vert_layout.addWidget(clear_btn)
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
vert_layout.addWidget(separator)
# container for table or grid
container = QWidget()
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setMinimumWidth(700)
scroll_area.setMaximumWidth(1500)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# delete frame of ScrollArea to get a flat look
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setWidget(container)
vert_layout.addSpacing(20)
vert_layout.addWidget(scroll_area)
# springs left and right of container to center the it
outer_layout.addStretch(1)
outer_layout.addLayout(vert_layout, stretch=100)
outer_layout.addStretch(1)
outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Wir geben dem Container ein vertikales Layout
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
# grid for table without direct container
self.header_grid = QGridLayout()
self.header_grid.setColumnStretch(0, 1)
self.header_grid.setColumnStretch(1, 1)
self.header_grid.setColumnStretch(2, 1)
self.header_grid.setColumnStretch(3, 1)
self.header_grid.setColumnMinimumWidth(4, 100)
self.grid = QGridLayout()
self.grid.setSpacing(10)
self.grid.setColumnStretch(0, 1)
self.grid.setColumnStretch(1, 1)
self.grid.setColumnStretch(2, 1)
self.grid.setColumnStretch(3, 1)
self.grid.setColumnMinimumWidth(4, 100)
# add grid to vertical layout
container_layout.addLayout(self.header_grid)
container_layout.addLayout(self.grid)
# spring below the layout to push it to top
container_layout.addStretch()
self.current_row = 0
headers = [
"UN/ NWP/ Kontaktperson",
"Individualberatung",
"Pauschalberatung",
"Verlaufsprotokoll",
"Datum",
]
for col_idx, title in enumerate(headers):
self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx)
# self.update_grid()
return main_widget
def _clear_layout(self) -> None:
clear_layout(self.grid)
# def update_grid(self) -> None:
# clear_layout(self.grid)
# data = backend.main_page_get_entries()
# for entry in data:
# self.add_row_to_grid(entry)
# TODO rework
# def add_row_to_grid(
# self,
# entry: types.MainPageEntry,
# ):
# row = self.current_row
# # NAME
# cell_name = MainPage_ClickableCell(entry.display_name, entry)
# cell_name.clicked.connect(self.goto_initial_recording)
# self.grid.addWidget(cell_name, row, 0)
# # DATE
# cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT)
# cell_date = MainPage_ClickableCell(cell_date_name, entry)
# cell_date.clicked.connect(self.goto_initial_recording)
# self.grid.addWidget(cell_date, row, 4)
# self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen
# TODO rework
# def goto_initial_recording(
# self,
# data: types.MainPageEntry,
# ):
# target: QWidget
# if data.type is types.RecordingType.COMPANY:
# ids = EntityIds(un_id=data.rec_id, type=RecordingType.COMPANY)
# new_state = Page_InitRecCompany_State(
# session=self.STATE.session,
# ids=ids,
# # un_id=data.rec_id, # TODO remove
# locked=True,
# )
# self.initrec_company.load_state(new_state)
# target = self.initrec_company
# elif data.type is types.RecordingType.PERSON:
# ids = EntityIds(pers_id=data.rec_id, type=RecordingType.PERSON)
# new_state = Page_InitRecPerson_State(
# session=self.STATE.session,
# ids=ids,
# # pers_id=data.rec_id, # TODO remove
# locked=True,
# )
# self.initrec_person.load_state(new_state)
# target = self.initrec_person
# else:
# raise TypeError(f"Unknown init rec type: {data.type}")
# self._navigate_to_target(target)
def _navigate_to_target(
self,
widget: QWidget,
) -> None:
curr_idx = self.stack.currentIndex()
if curr_idx != -1:
self._history_stack.append(curr_idx)
self.stack.setCurrentWidget(widget)
def simple_go_back(self) -> None:
if not self._history_stack:
logger_gui.info("[main program] Called >back< on empty history")
prev_idx = self._history_stack.pop()
self.stack.setCurrentIndex(prev_idx)
def show_main_page(self):
self._history_stack.clear()
self._navigate_to_target(self.main_page)
def show_new_entry_select(self):
self._navigate_to_target(self.new_entry_select)
def show_page_newinitrec(
self,
new_state: Page_InitRec_State,
):
self.newinitrec.load_state(new_state)
self._navigate_to_target(self.newinitrec)
# def show_page_initrec_company(
# self,
# new_state: Page_InitRecCompany_State,
# ):
# self.initrec_company.load_state(new_state)
# self._navigate_to_target(self.initrec_company)
# def show_page_initrec_person(
# self,
# new_state: Page_InitRecPerson_State,
# ):
# self.initrec_person.load_state(new_state)
# self._navigate_to_target(self.initrec_person)
def show_page_company_profile(
self,
new_state: Page_CompanyProfile_State,
) -> None:
self.company_profile.load_state(new_state)
self._navigate_to_target(self.company_profile)
def update_page_company_profile(self) -> None:
self.company_profile.update_current_page()
def show_page_consulting(
self,
new_state: Page_Consulting_State,
) -> None:
self.consulting_page.load_state(new_state)
self._navigate_to_target(self.consulting_page)
# --- MENÜ LOGIK ---
def create_menu(self):
menu_bar = self.menuBar()
# file menu
file_menu = menu_bar.addMenu("Datei")
exit_action = QAction("Beenden", self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# help menu
help_menu = menu_bar.addMenu("Hilfe")
about_action = QAction("Über", self)
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 = get_message_box(
QMessageBox.Icon.Critical,
"Kritischer Fehler",
"Ein unerwarteter Fehler ist aufgetreten. Die Details wurden protokolliert.",
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 TYPE_CHECKING:
# TODO add all relevant modules
# enable static type checking for the protocol
_m1: type[Module] = Page_Consulting
_m2: type[Module] = Page_Consulting_Table
_m3: type[Module] = Page_Consulting_ConsultingSession
_m4: type[Module] = Page_InitRec
_m5: type[Module] = InitRecForm
# _w1: type[WrapperModule] = Page_InitRecCompany
# _w2: type[WrapperModule] = Page_InitRecPerson
_f1: type[CustomForm] = DynamicListWidget
_f2: type[CustomForm] = DynamicDropdownWidgetNumeric
_f3: type[CustomForm] = DynamicDropdownWidgetOption
def main() -> None:
sys.excepthook = global_exception_handler
qInstallMessageHandler(qt_message_handler)
try:
app = QApplication(sys.argv)
app.setStyleSheet(QSS)
translator = QTranslator()
translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if translator.load(QLocale.system(), "qtbase", "_", translation_path):
app.installTranslator(translator)
window = MainWindow()
window.show()
sys.exit(app.exec())
except Exception as err:
logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True)
if __name__ == "__main__":
main()