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 COLUMN_SEP, TIMEZONE_CEST from wce_crm.data_models import ( Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, Grunderfassung_Dokument, InitRec, Page_CompanyProfile_State, Page_Consulting_ConsultingSession_State, Page_Consulting_Linking_State, Page_Consulting_State, Page_Consulting_Table_State, Page_InitRec_DocumentSection_Doc_State, Page_InitRec_DocumentSection_State, Page_InitRec_Form_Data, Page_InitRec_Form_State, Page_InitRec_State, Page_MainPage_State, Page_NewEntry_State, Session, set_page_state, ) from wce_crm.form_defs import ( FormField, FormFieldType, ) from wce_crm.gui_components.dialogs import ( GUI_validation_error_handling, get_message_box, get_user_confirmation, pydantic_validation_error_handling, ) from wce_crm.gui_components.initrec_file_selection import DocumentSectionWidget from wce_crm.logging import ( logger_custom_fields, logger_get_data, logger_gui, logger_initrecform, logger_page_consulting, logger_page_initrec, ) from wce_crm.types import ( CompanyProfileConsultationEntry, ConsultingType, EntityIds, EntityType, RecId, RecordingType, ) if TYPE_CHECKING: from wce_crm.data_models import ( Module, Page_InitRec_Form_State, ) from wce_crm.types import ( ConsId, LinkingConsultationsEntryCompany, LinkingConsultationsEntryPerson, ) K = TypeVar("K") V = TypeVar("V") T = TypeVar("T") W = TypeVar("W", bound="QWidget") DEBUG: bool = True 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, datetime.date)) or value is None try: widget.set_pydate(value, raise_error=True) except ValueError: raise ValueError(f"Could not parse date field value >{value}<") elif isinstance(widget, FlexibleDateTimeInput): assert isinstance(value, datetime.datetime) or value is None try: widget.set_pydatetime(value, raise_error=True) except ValueError: raise ValueError(f"Could not parse datetime field value >{value}<") elif isinstance(widget, QComboBox): index = widget.findData(value) if index >= 0: 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_CONTACT_KEY = "kontaktweg" lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_CONTACT_KEY) assert len(lookup) == 1 self.channel_contact = cast(QComboBox, lookup[0]["widget"]) self.DATA_EXPORT_FIELDS = ( self.COMPANY_SEARCH_INPUT_KEY, self.PERSON_SEARCH_INPUT_KEY, self.CHANNEL_CONTACT_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_contact.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.external_company_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? logger_initrecform.info( "[InitRecForm] Pydantic model name: %s", self.STATE.cfg.pydantic_model.__name__, ) 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 | datetime.date | None, raise_error: bool = False, ) -> None: if date is None: self.reset() return date_to_set = QDate(date.year, date.month, date.day) if raise_error and not date_to_set.isValid(): raise ValueError("Could not parse date object correctly") self.set_date(date_to_set) def get_date(self) -> QDate: # valid or invalid QDate return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy") def get_pydate(self) -> datetime.date | None: # valid or invalid QDate date = self.get_date() if not date.isValid(): return None py_date = cast(datetime.date, date.toPython()) return py_date def reset(self) -> None: self.calendar.hide() self.line_edit.clear() class FlexibleDateTimeInput(QWidget): def __init__(self, parent=None): super().__init__(parent) self._read_only: bool = False self._required: bool = False layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Simple text field allows for easy editing and deletion (with Time) self.line_edit = QLineEdit() self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") self.line_edit.setInputMask("99.99.9999 99:99;_") # force datetime structure self.button = QPushButton("📅") self.button.setFixedWidth(30) self.line_edit.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) layout.addWidget(self.line_edit) layout.addWidget(self.button) self.calendar = QCalendarWidget() self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday) self.calendar.setGridVisible(True) self.calendar.setStyleSheet(""" QCalendarWidget QAbstractItemView { selection-background-color: #2980b9; selection-color: white; } """) self.button.clicked.connect(self.show_calendar) self.calendar.activated.connect(self.set_date_from_calendar) # signal to validate self.line_edit.editingFinished.connect(self._validate_input) self.line_edit.installEventFilter(self) self.setReadOnly(False) def eventFilter( self, watched: QObject, event: QEvent, ): # press enter: direct validation if watched == self.line_edit: if event.type() == QEvent.Type.KeyPress: if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore self._validate_input() return True # mark event as processed elif event.type() == QEvent.Type.FocusOut: self._validate_input() # no return to not block focus change return super().eventFilter(watched, event) def show_calendar(self) -> None: if self._read_only: return pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft()) self.calendar.move(pos) self.calendar.show() def isRequired(self) -> bool: return self._required def setRequired( self, required: bool, ) -> None: self._required = required def isReadOnly(self) -> bool: return self._read_only def setReadOnly( self, read_only: bool, style_class_readonly: str = DEFAULT_READONLY_STYLECLASS, ): self._read_only = read_only self.line_edit.setReadOnly(read_only) self.button.setEnabled(not read_only) current_text = self.line_edit.text() if read_only: self.line_edit.setInputMask("") self.line_edit.setPlaceholderText("kein Zeitstempel eingetragen") pure = ( current_text.replace(".", "") .replace(":", "") .replace(" ", "") .replace("_", "") .strip() ) cleaned_datetime = current_text if pure else "" self.line_edit.setText(cleaned_datetime) self.line_edit.setProperty("styleClass", style_class_readonly) else: self.line_edit.setInputMask("99.99.9999 99:99;_") self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm") self.line_edit.setProperty("styleClass", "") self.line_edit.style().unpolish(self.line_edit) self.line_edit.style().polish(self.line_edit) self.line_edit.update() def _validate_input( self, ) -> None: logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation") if self._read_only: return focused_widget = QApplication.focusWidget() if focused_widget == self.button or focused_widget == self.calendar: return text = self.line_edit.text() # field empty (ignoring dots, colons, spaces, underscores) pure_text = ( text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip() ) if not self._required and not pure_text: self.line_edit.setStyleSheet("") return # check logic for QDateTime dt = QDateTime.fromString(text, "dd.MM.yyyy hh:mm") if not dt.isValid(): self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;") self.line_edit.selectAll() else: self.line_edit.setStyleSheet("") def validate( self, ) -> bool: logger_custom_fields.debug("[CustomField - FlexDatetime]: Call validate...") self._validate_input() datetime = self.get_datetime() if datetime and datetime.isValid(): logger_custom_fields.debug("[CustomField - FlexDatetime]: returning valid") return True logger_custom_fields.debug("[CustomField - FlexDatetime]: returning invalid") return False def set_date_from_calendar( self, date: QDate, ) -> None: if self._read_only: return # save time user already provided it current_text = self.line_edit.text() time_part = "00:00" # extract time portion (last 5 characters of "dd.MM.yyyy hh:mm") if len(current_text) >= 16: potential_time = current_text[11:16] if "_" not in potential_time: # only if time was fully provided time_part = potential_time if date and date.isValid(): formatted_date = date.toString("dd.MM.yyyy") self.line_edit.setText(f"{formatted_date} {time_part}") self.line_edit.setStyleSheet("") else: self.line_edit.clear() self.calendar.hide() def set_datetime( self, dt: QDateTime, ) -> None: if dt and dt.isValid(): self.line_edit.setText(dt.toString("dd.MM.yyyy hh:mm")) self.line_edit.setStyleSheet("") else: self.line_edit.clear() def set_pydatetime( self, dt: datetime.datetime | None, raise_error: bool = False, ) -> None: if dt is None: self.reset() return dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0) if raise_error and not dt_to_set.isValid(): raise ValueError("Could not parse datetime object correctly") self.set_datetime(dt_to_set) def get_datetime(self) -> QDateTime: # valid or invalid QDateTime return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm") def get_pydatetime(self) -> datetime.datetime | None: """returns current value of the field as UTC datetime Returns ------- datetime.datetime UTC datetime object """ # valid or invalid datetime dt = self.get_datetime() if not dt.isValid(): return None py_dt = cast(datetime.datetime, dt.toPython()) py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST) return py_dt.astimezone(datetime.UTC) def reset(self) -> None: self.calendar.hide() self.line_edit.clear() class DynamicListWidget(QWidget): """ 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_InitRec_State) # Signal "Unternehmen" # person_requested = Signal(Page_InitRec_State) # Signal "Individualperson" new_consulting_requested = Signal(Page_Consulting_State) # Signal "Beratungsgespräch" new_initrec_requested = Signal(Page_InitRec_State) # TODO: rename def __init__( self, state: Page_NewEntry_State, ): super().__init__() self.STATE = state outer_layout = QHBoxLayout(self) layout_container = QWidget() layout_container.setMinimumWidth(700) layout_container.setMaximumWidth(1000) layout = QVBoxLayout(layout_container) outer_layout.addStretch(1) outer_layout.addWidget(layout_container, stretch=100) # outer_layout.addWidget(scroll_area, stretch=100) outer_layout.addStretch(1) outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) back_btn = QPushButton("← Zurück zur Übersicht") back_btn.setFixedWidth(200) back_btn.clicked.connect(lambda: self.back_requested.emit()) self.title_label = QLabel("Wählen Sie den Typ der Grunderfassung") self.title_label.setStyleSheet( "font-size: 24px; font-weight: bold; margin-top: 20px;" ) title_initrec = QLabel("Grunderfassung anlegen") title_initrec.setStyleSheet( "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" ) btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) btn_company.clicked.connect(self._request_initrec_company) btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) btn_person.clicked.connect(self._request_initrec_person) title_consulting = QLabel("Beratung durchführen") title_consulting.setStyleSheet( "font-size: 16px; font-weight: bold; margin-top: 20px; margin-bottom: 5px;" ) btn_consulting = QPushButton("Beratungsgespräch →") btn_consulting.setFixedWidth(300) btn_consulting.setFixedHeight(40) btn_consulting.clicked.connect(self._request_consulting) # TODO check removal # btn_newinitrec = QPushButton("NEUE GRUNDERFASSUNG →") # btn_newinitrec.setFixedWidth(300) # btn_newinitrec.setFixedHeight(40) # btn_newinitrec.clicked.connect(self._debug_request_new_initrec) layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) # layout.addSpacing(20) # layout.addWidget(btn_newinitrec) # layout.addSpacing(20) layout.addWidget(title_initrec) layout.addWidget(btn_company) layout.addWidget(btn_person) layout.addSpacing(10) layout.addWidget(title_consulting) layout.addWidget(btn_consulting) # TODO configure correctly def _request_initrec_company(self) -> None: logger_gui.debug("[Page -- NewEntry] Call for company...") logger_gui.debug("[Page -- NewEntry] Call for person...") req_state = Page_InitRec_State( session=self.STATE.session, recording_type=RecordingType.WITH_COMPANY, form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) self.new_initrec_requested.emit(req_state) def _request_initrec_person(self) -> None: logger_gui.debug("[Page -- NewEntry] Call for person...") req_state = Page_InitRec_State( session=self.STATE.session, recording_type=RecordingType.WITHOUT_COMPANY, form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) self.new_initrec_requested.emit(req_state) def _debug_request_new_initrec(self) -> None: req_state = Page_InitRec_State( session=self.STATE.session, recording_type=RecordingType.WITH_COMPANY, form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) logger_gui.debug("[Page -- NewEntry] State to call (IDs): %s", req_state.ids) self.new_initrec_requested.emit(req_state) def _request_consulting(self) -> None: req_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=None, beratungs_typ=ConsultingType.PAUSCHAL, locked=False, ) logger_gui.debug("[Page -- InitRec] State to call: %s", req_state) self.new_consulting_requested.emit(req_state) def _sync_state_to_GUI(self) -> None: pass def _sync_GUI_to_state(self) -> None: pass def validate(self) -> None: pass def load_state( self, new_state: Page_NewEntry_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI() def get_state(self) -> Page_NewEntry_State: self._sync_GUI_to_state() return self.STATE def save_data(self) -> None: pass # CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[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: str = "", parent: QWidget | None = None, ) -> None: super().__init__(parent) # 1. Header-Button mit Pfeil self.toggle_button = QToolButton() self.base_text = f" {title}" self.toggle_button.setText(self.base_text) self.toggle_button.setCheckable(True) self.toggle_button.setChecked(False) self.toggle_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.toggle_button.setArrowType(Qt.ArrowType.RightArrow) self.toggle_button.setStyleSheet( "QToolButton { border: none; font-weight: bold; font-size: 13px; }" ) self.toggle_button.toggled.connect(self._on_toggle) # 2. Inhaltsbereich (standardmäßig ausgeblendet) self.content_area = QWidget() self.content_area.setVisible(False) # Layout des Box-Containers main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 5, 0, 5) main_layout.addWidget(self.toggle_button) main_layout.addWidget(self.content_area) def _on_toggle( self, checked: bool, ) -> None: # Pfeilrichtung ändern & Inhalt ein-/ausblenden self.toggle_button.setArrowType( Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow ) self.content_area.setVisible(checked) def setContentLayout( self, layout: QLayout, ) -> None: self.content_area.setLayout(layout) def setContentWidget( self, widget: QWidget, ) -> None: layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Keine zusätzlichen Ränder layout.addWidget(widget) self.setContentLayout(layout) def set_additional_text( self, text: str, ) -> None: new_txt = self.base_text + text self.toggle_button.setText(new_txt) def set_open( self, open: bool, ) -> None: self._on_toggle(open) class Page_InitRec(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button update_triggered = Signal() # form saved (data changed for front page) company_profile_requested = Signal(Page_CompanyProfile_State) def __init__( self, 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) self.comp_profile_btn = QPushButton("Unternehmensprofil") self.comp_profile_btn.clicked.connect(self._request_company_profile) self.comp_profile_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.comp_profile_btn.setFixedHeight(50) header_layout.addWidget(self.comp_profile_btn) vert_layout.addWidget(header_container) if DEBUG: separator1 = QFrame() separator1.setFrameShape(QFrame.Shape.HLine) separator1.setFrameShadow(QFrame.Shadow.Sunken) header_layout.addWidget(separator1) btn_gui2state = QPushButton("Sync GUI to state") btn_gui2state.clicked.connect(self._sync_GUI_to_state) btn_gui2state.setFixedHeight(35) btn_gui2state.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) header_layout.addWidget(btn_gui2state) btn_state2gui = QPushButton("Sync state to GUI") btn_state2gui.clicked.connect(self._sync_state_to_GUI) btn_state2gui.setFixedHeight(35) btn_state2gui.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) header_layout.addWidget(btn_state2gui) btn_print_state = QPushButton("Print State") btn_print_state.clicked.connect(self._debug_print_state) btn_print_state.setFixedHeight(35) btn_print_state.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) header_layout.addWidget(btn_print_state) separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) header_layout.addWidget(separator2) header_layout.addSpacing(5) # --- BANNER --- self.info_banners_container = QWidget() self.info_banners: list[InfoBanner] = [] self.info_banners_layout = QVBoxLayout(self.info_banners_container) self.info_banners_layout.setContentsMargins(0, 0, 0, 0) self.info_banners_layout.setSpacing(6) header_layout.addWidget(self.info_banners_container) # --- BUTTONS --- self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=True) modify_btn = self.edit_buttons.modify_button assert modify_btn self.modify_btn = modify_btn reset_btn = self.edit_buttons.reset_btn assert reset_btn self.reset_btn = reset_btn self.save_btn = self.edit_buttons.save_btn self.delete_btn = self.edit_buttons.delete_btn self.modify_btn.modify_activated.connect(self.unlock) self.modify_btn.modify_deactivated.connect(self.lock) self.save_btn.clicked.connect(self.save_data) self.reset_btn.clicked.connect(self._trigger_reset_btn) self.delete_btn.clicked.connect(self._delete_data) header_layout.addSpacing(5) header_layout.addWidget(self.edit_buttons) # --- MAIN CONTENT --- container = QWidget() # SCROLL AREA 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) # ** metadata self.initrec_form_metadata_box = CollapsibleBox("Metadaten") ent_type = EntityType.METADATA self.initrec_form_metadata = InitRecForm(self.STATE.initrec_form_states[ent_type]) assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_metadata.STATE self.STATE.child_modules.append(self.initrec_form_metadata) self.initrec_form_metadata_box.setContentWidget(self.initrec_form_metadata) self.container_layout.addWidget(self.initrec_form_metadata_box) # --- CUSTOM LOGIC --- # ** 'Wiedereintrittsdatum' -> only relevant without company search_res = search_widgets_by_key( self.initrec_form_metadata.widget_registry, "wiedereintrittsdatum" ) print(f">>>> {pformat(search_res)}") assert len(search_res) == 1 field_wiedereintrittsdatum = search_res[0]["widget"] assert isinstance(field_wiedereintrittsdatum, FlexibleDateInput) self.field_wiedereintrittsdatum = field_wiedereintrittsdatum # ** person self.initrec_form_person_box = CollapsibleBox("Angaben zur Person") ent_type = EntityType.PERSON self.initrec_form_person = InitRecForm(self.STATE.initrec_form_states[ent_type]) assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_person.STATE self.STATE.child_modules.append(self.initrec_form_person) self.initrec_form_person_box.setContentWidget(self.initrec_form_person) self.container_layout.addWidget(self.initrec_form_person_box) # --- CUSTOM LOGIC --- # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' person_location = search_widgets_by_key( self.initrec_form_person.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort", ) assert len(person_location) == 1 self.person_location = cast(QComboBox, person_location[0]["widget"]) assert isinstance(self.person_location, QComboBox) self.person_location.currentIndexChanged.connect(self._custom_county_selection) selection_county = search_widgets_by_key( self.initrec_form_person.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" ) assert len(selection_county) == 1 self.selection_county = cast(QComboBox, selection_county[0]["widget"]) assert isinstance(self.selection_county, QComboBox) self.selection_county.setProperty("styleClass", "stempel") self.selection_county.setEnabled(False) # ** company ent_type = EntityType.COMPANY self.initrec_form_company_box = CollapsibleBox("Angaben zum Unternehmen") self.initrec_form_company = InitRecForm(self.STATE.initrec_form_states[ent_type]) self.STATE.child_modules.append(self.initrec_form_company) assert self.STATE.initrec_form_states[ent_type] is self.initrec_form_company.STATE target_idx = self.container_layout.indexOf(self.initrec_form_person_box) self.initrec_form_company_box.setContentWidget(self.initrec_form_company) self.container_layout.insertWidget(target_idx, self.initrec_form_company_box) # --- CUSTOM LOGIC --- # ** fill 'Kontaktperson -> Namen Unternehmen' search_res = search_widgets_by_key( self.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"] ) # ** documents self.docs_box = CollapsibleBox("Dokumente") docs_widget_state = Page_InitRec_DocumentSection_State( session=self.STATE.session, docs=self.STATE.docs, locked=self.STATE.locked, geloescht=self.STATE.geloescht, ) self.docs_widget = DocumentSectionWidget(docs_widget_state) self.docs_box.setContentWidget(self.docs_widget) self.container_layout.addWidget(self.docs_box) self.docs_widget.doc_count_changed.connect(self._trigger_update_docs_box_title) self.container_layout.addStretch() self._update_layout() def _debug_print_state(self) -> None: self._sync_GUI_to_state() logger_page_initrec.info( "[Page -- InitRec] Printing state IDs:\n%s", pformat(self.STATE.ids) ) logger_page_initrec.info( "[Page -- InitRec] Printing state Docs:\n%s", pformat(self.STATE.docs) ) def _request_company_profile(self) -> None: assert self.STATE.ids.un_id is not None, ( "requested company profile for a company which is not known (no ID)" ) req_state = Page_CompanyProfile_State( session=self.STATE.session, un_id=self.STATE.ids.un_id, ) logger_page_initrec.debug("[Page -- InitRec] State to call: %s", req_state) self.company_profile_requested.emit(req_state) def _trigger_update_docs_box_title( self, count: int, ) -> None: add_txt = f" ({count})" self.docs_box.set_additional_text(add_txt) def _update_layout(self) -> None: self.initrec_form_metadata_box.set_open(True) if self.STATE.recording_type is RecordingType.WITH_COMPANY: # add company self.initrec_form_company_box.setVisible(True) self.initrec_form_metadata.top_level_form_layout.setRowVisible( self.field_wiedereintrittsdatum, False ) self.comp_profile_btn.setVisible(True) if self.STATE.ids.valid(EntityType.COMPANY): self.comp_profile_btn.setEnabled(True) else: self.comp_profile_btn.setEnabled(False) else: self.initrec_form_company_box.setVisible(False) self.initrec_form_metadata.top_level_form_layout.setRowVisible( self.field_wiedereintrittsdatum, True ) self.comp_profile_btn.setVisible(False) self.comp_profile_btn.setEnabled(False) # ** custom logic def _custom_county_selection(self, idx: int) -> None: value = self.person_location.itemData(idx) if value == "Inland": 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 _trigger_reset_btn(self) -> None: assert not self.STATE.locked, "reset initialised, even though form is locked" confirm = get_user_confirmation( self, "Zurücksetzen bestätigen", ( "Soll der Eintrag wirklich zurückgesetzt werden? Alle nicht " "gespeicherten Änderungen gehen verloren." ), ) if not confirm: return self._sync_state_to_GUI(None) def _delete_data(self) -> None: assert self._deletion_valid(), "deletion initialised despite IDs are not properly set" confirm = get_user_confirmation( self, "Löschen bestätigen", "Soll der Eintrag wirklich gelöscht werden?", ) if not confirm: return self.STATE.geloescht = True self.save_data() self.update_triggered.emit() def _auto_form_updated(self) -> None: self._sync_GUI_to_state() self._sync_state_to_GUI("banners") # TODO saving logic to connect company and person if needed def _reset(self) -> None: for form in self.STATE.child_modules: form.reset_form() self.STATE.docs.clear() self.STATE.ids = EntityIds() self.STATE.geloescht = False def _load_database( self, ids: EntityIds, ) -> None: logger_initrecform.info("[Page -- InitRec] Load data method...") logger_initrecform.debug("[Page -- InitRec] Entity IDs: %s", ids) self._reset() # ids = EntityIds( # # un_id=3, # # an_id=7, # pers_id=5, # ) # # ids = EntityIds( # # link_id=7, # # ) self.STATE.ids.update(ids, ignore_none=False) if ids.empty(): logger_initrecform.debug("[Page -- InitRec] Loading: No ID set. Do nothing!") return initrec = backend.page_initrec_from_db(ids) # TODO re-enable later # if res.status != STATUS_HANDLER.SUCCESS: # logger_page_consulting.error( # ( # "[Consulting Page] There were errors during the database " # "loading procedure." # ), # stack_info=True, # ) # exc_formatted = ( # f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" # ) # msg_box = get_message_box( # QMessageBox.Icon.Warning, # "Laden fehlgeschlagen", # ( # "Beim Laden der Daten ist ein Fehler aufgetreten. Details sind " # "unten angefügt." # ), # detailed_text=exc_formatted, # ) # msg_box.exec() # return self._load_from_pydantic(initrec, sync_to_GUI=False) def _load_from_pydantic( self, data: InitRec, update_only: bool = False, sync_to_GUI: bool = True, ) -> None: logger_initrecform.info("[Page -- InitRec] Load from Pydantic model...") self.STATE.ids.update(data.ids) self.STATE.recording_type = data.recording_type self.STATE.form_data = data.form_data self.STATE.geloescht = True if data.geloescht else False # set form data to correct initrec form states for ent_type, state in self.STATE.initrec_form_states.items(): state.ids.update(self.STATE.ids) state.geloescht = self.STATE.geloescht match ent_type: case EntityType.COMPANY: state.form_data = self.STATE.form_data.company if ( not update_only and self.STATE.recording_type is RecordingType.WITH_COMPANY ): self.initrec_form_company.load_state(state) case EntityType.PERSON: state.form_data = self.STATE.form_data.person if not update_only: self.initrec_form_person.load_state(state) case EntityType.METADATA: state.form_data = self.STATE.form_data.metadata logger_initrecform.info( "[Page -- InitRec] Load METADATA from Pydantic model...\n%s", pformat(self.STATE.form_data.metadata), ) self.initrec_form_metadata.load_state(state) case _: raise RuntimeError(f"Unspecified entity type {ent_type}") if update_only: relevant_gui_states = [s for s in self.STATE.docs if not s.geloescht] rel_doc_states = [s for s in data.docs if s.geloescht is None] assert len(relevant_gui_states) == len(rel_doc_states) for gui_state, pydantic_state in zip(relevant_gui_states, rel_doc_states): gui_state.id = pydantic_state.id gui_state.speicher_pfad = pydantic_state.speicher_pfad else: self.STATE.docs.clear() for pydantic_state in data.docs: # Deleted sessions are not part of the retrieved data from the backend if this # page is initialised from a fresh backend call to get data. (Updates do # indeed have data which must be filtered -- see above) doc_state = Page_InitRec_DocumentSection_Doc_State( session=self.STATE.session, id=pydantic_state.id, speicher_pfad=pydantic_state.speicher_pfad, dateiname_original=pydantic_state.dateiname_original, datum_hinzugefuegt=pydantic_state.datum_hinzugefuegt, mime_type=pydantic_state.mime_type, anmerkung=pydantic_state.anmerkung, locked=self.STATE.locked, ) self.STATE.docs.append(doc_state) if sync_to_GUI: self._sync_state_to_GUI(None) def _sync_state_to_GUI( self, section: Literal["content", "banners", "locking"] | None, ) -> None: logger_page_initrec.debug( "[Page -- InitRec] Syncing state to GUI: State IDs: %s", self.STATE.ids ) if section is None or section == "content": if not self.STATE.geloescht: self._load_database(self.STATE.ids) else: self._reset() self._update_layout() doc_widget_state = Page_InitRec_DocumentSection_State( session=self.STATE.session, docs=self.STATE.docs, locked=self.STATE.locked, geloescht=self.STATE.geloescht, ) self.docs_widget.load_state(doc_widget_state) if section is None or section == "banners": self._clear_info_banners() if self.STATE.ids.pers_id is None: self._add_info_banner( text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", button_text="", ) if section is None or section == "locking": if self.STATE.locked: for form in self.STATE.child_modules: form.lock() self.docs_widget.lock() self.edit_buttons.set_modify_state(False, set_delete_btn=True) else: for form in self.STATE.child_modules: form.unlock() self.docs_widget.unlock() self.edit_buttons.set_modify_state(True, set_delete_btn=False) self._activate_delete() def _sync_GUI_to_state(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Syncing GUI to state...") logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: Child modules...\n%s", pformat(self.STATE.child_modules), ) logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: Existing form states keys...\n%s", pformat(self.STATE.initrec_form_states.keys()), ) for form in self.STATE.child_modules: # ?? is this the same reference to use a similar pattern as # ?? the consulting page (initialise once with the same state # ?? as reference which are then automatically updated) # !! check if this is working as expected # new entries' states are linked to this instance by the page state form_state = form.get_state() logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: Form state ent_type %s", form_state.ent_type, ) logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: identity of form states: %s", (self.STATE.initrec_form_states[form_state.ent_type] is form_state), ) assert self.STATE.initrec_form_states[form_state.ent_type] is form_state, ( "passing by reference not working" ) self.STATE.ids.update(form_state.ids, ignore_none=True) # self.STATE.form_data[form_state.ent_type] = form_state.form_data match form_state.ent_type: case EntityType.COMPANY: self.STATE.form_data.company = form_state.form_data case EntityType.PERSON: self.STATE.form_data.person = form_state.form_data case EntityType.METADATA: self.STATE.form_data.metadata = form_state.form_data case _: raise RuntimeError(f"Unspecified entity type {form_state.ent_type}") docs_widget_state = self.docs_widget.get_state() self.STATE.docs = docs_widget_state.docs logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: IDs after...\n%s", pformat(self.STATE.ids), ) logger_page_initrec.debug( "[Page -- InitRec] Syncing GUI to state: Form data after...\n%s", pformat(self.STATE.form_data), ) def load_state( self, new_state: Page_InitRec_State, ) -> None: set_page_state( self.STATE, new_state, ignore_attr={"form_data", "initrec_form_states"}, ) self._sync_state_to_GUI(None) def get_state(self) -> Page_InitRec_State: self._sync_GUI_to_state() return self.STATE def validate(self) -> list[str]: errors: list[str] = [] for m in self.STATE.child_modules: if ( m.STATE.ent_type is EntityType.COMPANY and self.STATE.recording_type is not RecordingType.WITH_COMPANY ): continue errors.extend(m.validate()) errors.extend(self.docs_widget.validate()) return errors def lock(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Locking...") self.STATE.locked = True self._sync_state_to_GUI("locking") def unlock(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Unlocking...") self.STATE.locked = False self._sync_state_to_GUI("locking") def save_data(self) -> None: logger_page_initrec.debug("[Page -- InitRec] Save button clicked...") logger_page_initrec.info("[Page -- InitRec] Syncing current state...") # state = self.get_state() self._sync_GUI_to_state() form_data = self.STATE.form_data assert form_data, "form data not set" logger_page_initrec.debug("[Page -- InitRec] Get form data\n%s", pformat(form_data)) db_data = Page_InitRec_Form_Data(metadata={}, person={}, company={}) if self.STATE.ids.empty() and self.STATE.geloescht: logger_page_initrec.debug( "[Page -- InitRec] This was never saved in the database " "and is marked for deletion - ignore." ) return elif not self.STATE.ids.empty() and self.STATE.geloescht: logger_page_initrec.debug( "[Page -- InitRec] This was saved in the database " "and is marked for deletion - fast path." ) ts_deleted = datetime.datetime.now(datetime.UTC) assert self.STATE.form_data, "cannot delete entry with uninitialised form data" pydantic_docs: list[Grunderfassung_Dokument] = [] for doc_state in self.STATE.docs: if doc_state.id is None: logger_page_initrec.debug( ( "[Page -- InitRec] State which was never saved in the database " "and is marked for deletion - ignore." ) ) continue doc_state_dict = dc.asdict(doc_state) del doc_state_dict["geloescht"] doc = Grunderfassung_Dokument( geloescht=ts_deleted, pers_id=self.STATE.ids.pers_id, **doc_state_dict, ) pydantic_docs.append(doc) data_to_db = InitRec( ids=self.STATE.ids, recording_type=self.STATE.recording_type, geloescht=ts_deleted, form_data=db_data, docs=pydantic_docs, ) returned = backend.page_initrec_to_db(data_to_db) self._load_from_pydantic(returned) msg_box = get_message_box( QMessageBox.Icon.Information, "Löschen erfolgreich", "Der Eintrag wurde erfolgreich gelöscht", ) msg_box.exec() return self._disable_save() errors = self.validate() if errors: logger_page_initrec.error( "[Page -- InitRec] Error during GUI validation phase:\n%s", pformat(errors) ) GUI_validation_error_handling(errors) self._enable_save() return logger_page_initrec.info("[Page -- InitRec] Success! All data is valid") try: logger_page_initrec.debug("[Page -- InitRec] Call Pydantic...") for ent_type, state in self.STATE.initrec_form_states.items(): logger_page_initrec.debug( "[Page -- InitRec] Ent type: %s, Current state CFG model: %s", ent_type, state.cfg.pydantic_model, ) match ent_type: case EntityType.COMPANY: val_data = state.cfg.pydantic_model(**self.STATE.form_data.company) db_data.company = val_data.to_db(exclude=state.cfg.ignored_keys) case EntityType.PERSON: val_data = state.cfg.pydantic_model(**self.STATE.form_data.person) db_data.person = val_data.to_db(exclude=state.cfg.ignored_keys) case EntityType.METADATA: val_data = state.cfg.pydantic_model(**self.STATE.form_data.metadata) db_data.metadata = val_data.to_db(exclude=state.cfg.ignored_keys) case _: raise RuntimeError(f"Unspecified entity type {ent_type}") ts_deleted: datetime.datetime | None = None if self.STATE.geloescht: ts_deleted = datetime.datetime.now(datetime.UTC) pydantic_docs: list[Grunderfassung_Dokument] = [] for doc_state in self.STATE.docs: if doc_state.id is None and doc_state.geloescht: logger_page_initrec.debug( ( "[Page -- InitRec] State which was never saved in the database " "and is marked for deletion - ignore." ) ) continue ts_deleted_doc: datetime.datetime | None = None if ts_deleted is not None: # if process is deleted, all sessions must be marked as deleted ts_deleted_doc = ts_deleted elif doc_state.geloescht: ts_deleted_doc = datetime.datetime.now(datetime.UTC) doc_state_dict = dc.asdict(doc_state) del doc_state_dict["geloescht"] doc = Grunderfassung_Dokument( geloescht=ts_deleted_doc, pers_id=self.STATE.ids.pers_id, **doc_state_dict, ) pydantic_docs.append(doc) data_to_db = InitRec( ids=self.STATE.ids, recording_type=self.STATE.recording_type, geloescht=ts_deleted, form_data=db_data, docs=pydantic_docs, ) except ValidationError as e: logger_page_initrec.error( "[Page -- InitRec] Error during Pydantic validation phase:\n%s", pformat(e.errors()), ) pydantic_validation_error_handling(e) else: if DEBUG_NO_DATABASE: return # !! this code is only called if the 'try' block was successful # save data to database # db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) logger_page_initrec.debug( "[Page -- InitRec] Form data with 'exlude' (must be saved in the database):\n%s", pformat(db_data), ) try: # TODO change to result pattern returned = backend.page_initrec_to_db(data_to_db) self._load_from_pydantic(returned, update_only=True, sync_to_GUI=False) self.update_triggered.emit() except Exception as err: msg_box = get_message_box( QMessageBox.Icon.Warning, "Speichern fehlgeschlagen", ( "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " "unten angefügt." ), detailed_text=str(err), ) msg_box.exec() else: logger_page_initrec.info("[Page -- InitRec] Data saved successfully") msg_box = get_message_box( QMessageBox.Icon.Information, "Speichern erfolgreich", "Die Daten wurden erfolgreich gespeichert", ) msg_box.exec() finally: # always re-enable save, even if error occurred self._enable_save() class Page_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.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=None, un_id=self.STATE.un_id, beratungs_typ=cons_type, locked=False, ) self.consulting_requested.emit(new_state) def _request_existing_consulting( self, data: CompanyProfileConsultationEntry, ) -> None: assert self.STATE.un_id is not None, ( "tried to call consulting from comp profile without set ID" ) new_state = Page_Consulting_State( session=self.STATE.session, vorgang_id=data.cons_id, un_id=self.STATE.un_id, beratungs_typ=data.cons_type, locked=True, ) self.consulting_requested.emit(new_state) def _clear_consultations(self) -> None: logger_gui.debug("[Page Company Profile] Clear consultations...") while self.cons_pauschal: widget = self.cons_pauschal[0] self.layout_cons_pauschal.removeWidget(widget) self.cons_pauschal.remove(widget) widget.deleteLater() while self.cons_indiv: widget = self.cons_indiv[0] self.layout_cons_indiv.removeWidget(widget) self.cons_indiv.remove(widget) widget.deleteLater() def _update_consultations(self) -> None: if self.STATE.un_id is None: return consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.un_id) logger_gui.debug( "[Page -- Company Profile] Consultations: %s", pformat(consultations) ) self._clear_consultations() for entry in consultations.pauschal: date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) click_cell.clicked.connect(self._request_existing_consulting) target_idx = self.layout_cons_pauschal.count() - 1 self.layout_cons_pauschal.insertWidget(target_idx, click_cell) self.cons_pauschal.append(click_cell) for entry in consultations.individual: date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) click_cell.clicked.connect(self._request_existing_consulting) target_idx = self.layout_cons_indiv.count() - 1 self.layout_cons_indiv.insertWidget(target_idx, click_cell) self.cons_indiv.append(click_cell) def update_current_page(self) -> None: self._update_consultations() def _disable_add_btns(self) -> None: self.add_btn_pauschal.setEnabled(False) self.add_btn_individual.setEnabled(False) def _enable_add_btns(self) -> None: self.add_btn_pauschal.setEnabled(True) self.add_btn_individual.setEnabled(True) def _sync_state_to_GUI(self) -> None: if self.STATE.un_id is None: # just clear and do nothing else logger_gui.debug("just reset, ID is None") self.table_master_data.reset() self.set_company_name("") self._disable_add_btns() return self._enable_add_btns() # TODO change to correct backend function # raise RuntimeError("TODOs to implement") # init_rec = backend.initrec_company_get_initial_recording(self.STATE.un_id) # ma_id = cast("ExtMaId", init_rec["Partnersuche__un_suche"]) # assert ma_id is not None, "no ma_id obtained" comp_info = backend.external_company_get_info(self.STATE.un_id) data: dict[str, Any] = {} for feature, value in comp_info.items(): if feature in self.COMPANY_FEATURES_MASTER_DATA: data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value self.table_master_data.load_data(data) self.set_company_name(data["Name:"]) self.update_current_page() def _sync_GUI_to_state(self) -> None: pass def validate(self) -> None: pass def load_state( self, new_state: Page_CompanyProfile_State, ) -> None: logger_gui.debug("Called function to load with ID: %s", pformat(new_state.un_id)) self.STATE.un_id = new_state.un_id self._sync_state_to_GUI() def get_state(self) -> Any: ... def save_data(self) -> None: ... def set_company_name(self, name: str) -> None: if not name: name = self.change_title_text.text() if not name: name = "n.v." txt = f"Unternehmen: {name}" self.title_company_name.setText(txt) class MasterDataTable(QWidget): def __init__(self): super().__init__() self.setWindowTitle("Stammdaten Anzeige") self.resize(450, 400) layout = QVBoxLayout(self) layout.setContentsMargins(15, 15, 15, 15) # 1. Tabelle erstellen (0 Zeilen initial, 2 Spalten für Merkmal & Wert) self.table = QTableWidget(0, 2) # --- DER TRICK FÜR DEN CLEANEN LOOK --- self.table.horizontalHeader().hide() # Spalten-Header ausblenden self.table.verticalHeader().hide() # Zeilen-Header (Nummerierung) ausblenden self.table.setShowGrid(False) # Gitternetzlinien deaktivieren # Verhalten der Tabelle anpassen self.table.setSelectionMode( QAbstractItemView.SelectionMode.NoSelection ) # Nicht auswählbar self.table.setEditTriggers( QAbstractItemView.EditTrigger.NoEditTriggers ) # Schreibgeschützt # Spaltenbreite steuern: # Linke Spalte passt sich dem Text an, rechte Spalte füllt den Rest des Fensters self.table.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeMode.ResizeToContents ) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) layout.addWidget(self.table) # Daten laden self.load_data() self.style_table() def load_data(self): daten = [ ("Anwendungsname", "MyQtApp Pro"), ("Version", "2.4.1-beta"), ("Datenbank-Pfad", "/var/lib/sqlite/data.db"), ("Letzter Login", "2026-06-25 09:30"), ("Benutzer-ID", "USR-88291"), ("Verschlüsselung", "AES-256-GCM"), ] # Tabelle befüllen self.table.setRowCount(len(daten)) for zeile_index, (merkmal, wert) in enumerate(daten): # Merkmal-Zelle (Links) item_merkmal = QTableWidgetItem(merkmal) item_merkmal.setTextAlignment( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter ) self.table.setItem(zeile_index, 0, item_merkmal) # Wert-Zelle (Rechts) item_wert = QTableWidgetItem(wert) item_wert.setTextAlignment( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter ) self.table.setItem(zeile_index, 1, item_wert) def style_table(self): self.setStyleSheet(""" QWidget { background-color: transparent; } QTableWidget { border: none; background-color: transparent; } QTableWidget::item { padding: 8px; color: #334155; font-size: 14px; } QTableWidget::item:first-column { font-weight: bold; color: #64748b; padding-right: 20px; /* Mehr Abstand zum Wert */ } """) class MasterDataFormWidget(QWidget): def __init__( self, default_labels: Sequence[str], ): super().__init__() self.default_labels = default_labels self.default_data = {label: None for label in self.default_labels} # main layout main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) # form in frame segment_frame = QFrame() segment_frame.setObjectName("Segment") # form layout self.form_layout = QFormLayout(segment_frame) self.form_layout.setSpacing(15) self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) main_layout.addWidget(segment_frame) self.style_ui() self.reset() def load_data( self, data: dict[str, Any], ) -> None: self.clear_current_layout() for feature, value in data.items(): label_feature = QLabel(f"{feature}") label_feature.setObjectName("Merkmal") if value is None: value = "" label_value = QLabel(str(value)) label_value.setObjectName("Wert") label_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.form_layout.addRow(label_feature, label_value) def clear_current_layout(self): while self.form_layout.count() > 0: item = self.form_layout.takeAt(0) assert item is not None widget = item.widget() if widget is not None: widget.deleteLater() def reset(self) -> None: self.load_data(self.default_data) def style_ui(self): self.setStyleSheet(""" /* Hintergrund des Fensters */ QWidget { background-color: transparent; } /* Unser optisch abgetrenntes Segment */ QFrame#Segment { background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 5px; } /* Textgröße für alle Elemente im Formular hochsetzen */ QLabel { font-size: 12px; } /* Linke Spalte (Merkmale): Grau und Fett */ QLabel#Merkmal { font-weight: bold; /* color: #64748b; */ color: #475569; } /* Rechte Spalte (Werte): Dunkel und gut lesbar */ QLabel#Wert { color: #0f172a; } """) class Page_Consulting(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back to init rec (bool: reset yes/no) # TODO update should trigger something else # more like listening to 'child' ("Beratungen") update_triggered = Signal() # form saved (data changed for front page) def __init__( self, session: Session, ): super().__init__() # state management self.STATE = Page_Consulting_State( session=session, vorgang_id=None, beratungs_typ=ConsultingType.PAUSCHAL, locked=False, ) # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() # main_layout.setContentsMargins(0, 0, 0, 0) outer_layout.addStretch(1) outer_layout.addLayout(vert_layout, stretch=100) outer_layout.addStretch(1) # Optional: Damit der Container oben am Rand klebt outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # --- HEADER --- header_container = QWidget() header_container.setMinimumWidth(700) header_container.setMaximumWidth(1500) header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) 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 --- # TODO check if no reset button is correct self.edit_buttons = EditButtons(add_modify_btn=True, add_reset_btn=False) modify_btn = self.edit_buttons.modify_button assert modify_btn self.modify_btn = modify_btn # reset_btn = self.edit_buttons.reset_btn # assert reset_btn self.save_btn = self.edit_buttons.save_btn # self.reset_btn = reset_btn self.delete_btn = self.edit_buttons.delete_btn self.modify_btn.modify_activated.connect(self.unlock) self.modify_btn.modify_deactivated.connect(self.lock) self.save_btn.clicked.connect(self.save_data) # self.reset_btn.clicked.connect(self.reset_form) self.delete_btn.clicked.connect(self._delete_data) container_layout.addWidget(self.edit_buttons) type_group_box = QGroupBox("Typ") type_layout = QVBoxLayout(type_group_box) container_layout.addWidget(type_group_box) self.type_pauschal_btn = QRadioButton("Pauschalberatung") self.type_individual_btn = QRadioButton("Individualberatung") self.type_pauschal_btn.setChecked(True) self.type_group = QButtonGroup() self.type_group.addButton(self.type_pauschal_btn, 0) self.type_group.addButton(self.type_individual_btn, 1) self.type_group.idClicked.connect(self.type_consulting_changed) type_layout.addWidget(self.type_pauschal_btn) type_layout.addWidget(self.type_individual_btn) # container_layout.addSpacing(50) table_state = Page_Consulting_Table_State( session=self.STATE.session, locked=self.STATE.locked, ) 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.geloescht] rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None] assert len(relevant_gui_states) == len(rel_cons_session_states) for gui_state, pydantic_state in zip( relevant_gui_states, rel_cons_session_states ): gui_state.beratung_id = pydantic_state.beratung_id else: self.STATE.cons_sessions.clear() for pydantic_state in data.beratungen: # Deleted sessions are not part of the retrieved data from the backend if this # page is initialised from a fresh backend call to get data. (Updates do # indeed have data which must be filtered -- see above) cons_session_state = Page_Consulting_ConsultingSession_State( session=self.STATE.session, nutzer_id=pydantic_state.nutzer_id, nutzer_name=pydantic_state.nutzer_name, beratung_id=pydantic_state.beratung_id, zeitstempel=pydantic_state.zeitstempel, ansprechpartner=pydantic_state.ansprechpartner, kommunikationsweg=pydantic_state.kommunikationsweg, thema_crm_matrix=pydantic_state.thema_crm_matrix, anmerkungen=pydantic_state.anmerkungen, rueckmeldung=pydantic_state.rueckmeldung, locked=self.STATE.locked, ) self.STATE.cons_sessions.append(cons_session_state) if sync_to_GUI: self._sync_state_to_GUI(None) def get_state(self) -> Page_Consulting_State: self._sync_GUI_to_state() return self.STATE def _delete_entry(self) -> None: assert self.STATE.vorgang_id is not None and self.STATE.geloescht logger_page_consulting.debug( "[Consulting Page] This was saved in the database " "and is marked for deletion - fast path." ) res = backend.page_consulting_delete(self.STATE.vorgang_id) if res.status != STATUS_HANDLER.SUCCESS: logger_page_consulting.error( ( "[Consulting Page] There were errors during the database " "deletion procedure." ), stack_info=True, ) exc_formatted = ( f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" ) msg_box = get_message_box( QMessageBox.Icon.Warning, "Löschen fehlgeschlagen", ( "Beim Löschen der Daten ist ein Fehler aufgetreten. Details sind " "unten angefügt." ), detailed_text=exc_formatted, ) msg_box.exec() return logger_page_consulting.info("[Consulting Page] Data deleted successfully") msg_box = get_message_box( QMessageBox.Icon.Information, "Löschen erfolgreich", "Die Daten wurden erfolgreich gelöscht", ) msg_box.exec() self._sync_state_to_GUI(None) def save_data(self) -> None: logger_page_consulting.debug("[Consulting Page] Call saving data...") if self.STATE.vorgang_id is None and self.STATE.geloescht: logger_page_consulting.debug( "[Consulting Page] This was never saved in the database " "and is marked for deletion - ignore." ) return self._disable_save() errors = self.validate() if errors: logger_page_consulting.error( "[Consulting Page] Error during GUI validation phase:\n%s", pformat(errors) ) GUI_validation_error_handling(errors) self._enable_save() return logger_page_consulting.info("[Consulting Page] Success! All data is valid") logger_page_consulting.info("[Consulting Page] Get form data call...") self._sync_GUI_to_state() logger_page_consulting.debug("[Consulting Page] Current state:\n%s", self.STATE) try: logger_page_consulting.debug("[Consulting Page] Call Pydantic...") pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = [] timestamp_deleted: datetime.datetime | None = None if self.STATE.geloescht: timestamp_deleted = datetime.datetime.now(datetime.UTC) for row in self.STATE.cons_sessions: logger_page_consulting.debug("[Consulting Page] Row state:\n%s", row) if row.beratung_id is None and row.geloescht: logger_page_consulting.debug( ( "[Consulting Page] Session which was never saved in the database " "and is marked for deletion - ignore." ) ) continue timestamp_deleted_session: datetime.datetime | None = None if timestamp_deleted is not None: # if process is deleted, all sessions must be marked as deleted timestamp_deleted_session = timestamp_deleted elif row.geloescht: timestamp_deleted_session = datetime.datetime.now(datetime.UTC) cons_session_dict = dc.asdict(row) cons_session = Beratungsgespraech_Einzelgespraech( vorgang_id=self.STATE.vorgang_id, geloescht=timestamp_deleted_session, **cons_session_dict, ) pydantic_sessions.append(cons_session) consulting_process = Beratungsgespraech_Vorgang( vorgang_id=self.STATE.vorgang_id, un_id=self.STATE.un_id, pers_id=self.STATE.pers_id, titel=self.STATE.titel, beratungs_typ=self.STATE.beratungs_typ, # type: ignore geloescht=timestamp_deleted, beratungen=pydantic_sessions, ) except ValidationError as e: logger_page_consulting.error( "[Consulting Page] Error during Pydantic validation phase:\n%s", pformat(e.errors()), ) pydantic_validation_error_handling(e) else: if DEBUG_NO_DATABASE: return logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.") logger_page_consulting.debug( "[Consulting Page] Pydantic model:\n%s", consulting_process ) res = backend.page_consulting_to_db(consulting_process) if res.status != STATUS_HANDLER.SUCCESS: logger_page_consulting.error( ( "[Consulting Page] There were errors during the database " "saving/updating procedure." ), stack_info=True, ) exc_formatted = ( f"Exception: {res.status.ExceptionType}\nMessage: {res.status.message}" ) msg_box = get_message_box( QMessageBox.Icon.Warning, "Speichern fehlgeschlagen", ( "Beim Speichern der Daten ist ein Fehler aufgetreten. Details sind " "unten angefügt." ), detailed_text=exc_formatted, ) msg_box.exec() return updated_consulting_process = res.unwrap() logger_page_consulting.info( "[Consulting Page] Load state from modified Pydantic model" ) self._load_from_pydantic(updated_consulting_process, update_only=True) self.update_triggered.emit() logger_page_consulting.info("[Consulting Page] Data saved successfully") msg_box = get_message_box( QMessageBox.Icon.Information, "Speichern erfolgreich", "Die Daten wurden erfolgreich gespeichert", ) msg_box.exec() # self._activate_delete() finally: # always re-enable save, even if error occurred self._enable_save() def lock(self) -> None: self.STATE.locked = True self._sync_state_to_GUI("locking") def unlock(self) -> None: self.STATE.locked = False self._sync_state_to_GUI("locking") class Page_Consulting_LinkingDialog(QDialog): def __init__( self, state: Page_Consulting_Linking_State, parent: QWidget | None = None, ): super().__init__(parent) # State and data self.STATE = state self.all_companies = backend.page_consulting_linking_companies(None) self.all_persons = backend.page_consulting_linking_persons() self.setWindowTitle("Beziehung verknüpfen") self.resize(700, 450) # --- UI LAYOUT --- main_layout = QVBoxLayout(self) column_layout = QHBoxLayout() # left column: companies self.vbox_companies = QVBoxLayout() self.vbox_companies.addWidget(QLabel("Unternehmen wählen (Optional):")) self.btn_clear_company = QPushButton("Auswahl aufheben") self.list_companies = QListWidget() self.vbox_companies.addWidget(self.list_companies) self.vbox_companies.addWidget(self.btn_clear_company) # right column: persons self.vbox_persons = QVBoxLayout() self.vbox_persons.addWidget(QLabel("Person wählen (Optional):")) self.btn_clear_person = QPushButton("Auswahl aufheben") self.list_persons = QListWidget() self.vbox_persons.addWidget(self.list_persons) self.vbox_persons.addWidget(self.btn_clear_person) column_layout.addLayout(self.vbox_companies) column_layout.addLayout(self.vbox_persons) main_layout.addLayout(column_layout) # OK / Cancel buttons self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) self.ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok) self.ok_btn.setText("Verknüpfung speichern") self.ok_btn.setEnabled(False) self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen") main_layout.addWidget(self.button_box) # TODO check removal # list_style = """ # QListWidget::item:selected { # background-color: #0078D4; /* Blau, wenn die Liste aktiv fokussiert ist */ # color: white; # } # QListWidget::item:selected:!active { # background-color: #CCE4F7; /* Gut sichtbares Hellblau, wenn die Liste den Fokus verliert */ # color: black; # } # QListWidget::item:hover { # background-color: #F0F0F0; /* Dezentes Grau beim Drüberfahren */ # } # """ # self.list_companies.setStyleSheet(list_style) # self.list_persons.setStyleSheet(list_style) # signals self.list_companies.itemSelectionChanged.connect(self._company_selected) self.list_persons.itemSelectionChanged.connect(self._person_selected) self.btn_clear_company.clicked.connect(self._clear_company_selection) self.btn_clear_person.clicked.connect(self._clear_person_selection) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self._sync_state_to_GUI(reset=True) def _enable_person_selection( self, enable: bool, ) -> None: logger_page_consulting.debug(">>>>>>> CALL ENABLE: %s", enable) for i in range(self.vbox_persons.count()): item = self.vbox_persons.itemAt(i) if not item: continue widget = item.widget() logger_page_consulting.debug(">>>>>>> Type widget: %s", type(widget)) if isinstance(widget, QWidget): logger_page_consulting.debug(">>>>>>> Widget (enable=%s): %s", enable, widget) widget.setEnabled(enable) def _reset(self) -> None: self._clear_company_selection() self._clear_person_selection() def _sync_state_to_GUI( self, reset: bool, section: Literal["person_selection", "company_selection", "validation"] | None = None, ) -> None: if reset: self._reset() self._load_companies_to_list(None) self._load_persons_to_list(None) if section is None or section == "person_selection": if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: self._enable_person_selection(False) elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: self._enable_person_selection(True) if not self.list_persons.selectedItems(): self.list_persons.blockSignals(True) self._load_persons_to_list(self.STATE.un_id) self.list_persons.blockSignals(False) if section is None or section == "company_selection": if not self.list_companies.selectedItems(): self.list_companies.blockSignals(True) self._load_companies_to_list(self.STATE.filter_un_ids) self.list_companies.blockSignals(False) if section is None or section == "validation": self._validate() def _validate(self) -> None: valid: bool = False if self.STATE.beratungs_typ is ConsultingType.PAUSCHAL: assert self.STATE.pers_id is None if self.STATE.un_id is not None: valid = True elif self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL: if self.STATE.pers_id is not None: valid = True else: RuntimeError("Undefined consulting type") if valid: self.ok_btn.setEnabled(True) else: self.ok_btn.setEnabled(False) def _reload_all_companies(self) -> None: self.all_companies = backend.page_consulting_linking_companies(None) def _reload_all_persons(self) -> None: self.all_persons = backend.page_consulting_linking_persons() def _load_companies_to_list( self, un_ids: set[RecId] | None, ) -> None: # search_text = self.suche_firma.text().strip().lower() self.list_companies.clear() # TODO check reload of companies # companies = backend.page_consulting_linking_companies(None) logger_page_consulting.debug( "[Page Consulting -- Linking] Retrieved company entries:\n%s", self.all_companies ) for company in self.all_companies: if un_ids and company.un_id not in un_ids: continue item = QListWidgetItem(company.display_name) item.setData(Qt.ItemDataRole.UserRole, company) self.list_companies.addItem(item) def _load_persons_to_list( self, un_id: RecId | None, ) -> None: """Lädt Personen. Wenn firma_id übergeben wird, wird gefiltert.""" self.list_persons.clear() # TODO check reload of persons # persons = backend.page_consulting_linking_persons() logger_page_consulting.debug( "[Page Consulting -- Linking] Retrieved person entries:\n%s", self.all_persons ) for person in self.all_persons: if un_id and un_id not in person.un_ids: continue item = QListWidgetItem(person.display_name) item.setData(Qt.ItemDataRole.UserRole, person) self.list_persons.addItem(item) # --- LOGIK --- def _company_selected(self) -> None: items = self.list_companies.selectedItems() if not items: return company = cast( "LinkingConsultationsEntryCompany", items[0].data(Qt.ItemDataRole.UserRole) ) self.STATE.un_id = company.un_id logger_page_consulting.debug( "[Page Consulting -- Linking] Company selected. State is:\n%s", pformat(self.STATE), ) self._sync_state_to_GUI(reset=False) def _person_selected(self) -> None: items = self.list_persons.selectedItems() if not items: return person = cast( "LinkingConsultationsEntryPerson", items[0].data(Qt.ItemDataRole.UserRole) ) self.STATE.pers_id = person.pers_id if person.un_ids: self.STATE.filter_un_ids = person.un_ids else: self.STATE.filter_un_ids = None self._sync_state_to_GUI(reset=False) def _clear_company_selection(self) -> None: logger_page_consulting.debug( "[Page Consulting -- Linking] Clear company selection...", ) self.list_companies.clearSelection() self.STATE.un_id = None self._sync_state_to_GUI(reset=False) def _clear_person_selection(self) -> None: logger_page_consulting.debug( "[Page Consulting -- Linking] Clear person selection...", ) self.list_persons.clearSelection() self.STATE.pers_id = None self.STATE.filter_un_ids = None self._sync_state_to_GUI(reset=False) def get_state(self) -> Page_Consulting_Linking_State: return self.STATE class Page_Consulting_ConsultingSession(QWidget): """child of 'Page_Consulting_DynamicTable'""" delete_request = Signal(QWidget) def __init__( self, state: Page_Consulting_ConsultingSession_State, ) -> None: super().__init__() assert isinstance(state, Page_Consulting_ConsultingSession_State) self.STATE = state layout = QHBoxLayout(self) layout.setContentsMargins(0, 2, 0, 2) # Sehr kompakt layout.setSpacing(10) layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) ROW_HEIGHT = 70 self.widget_registry: WidgetRegistry = {} user_field_def = FormField[QLineEdit]( "Nutzername", FormFieldType.TEXT, readonly=True, placeholder="wird über aktiven Nutzer bei Anlage gesetzt", enable_uuid_key=True, ) _, self.user_field = build_and_add_widget(user_field_def, layout) # ?? date or datetime? timestamp_def = FormField[FlexibleDateTimeInput]( "Zeitstempel", FormFieldType.DATETIME_FLEXIBLE, required=True, key="zeitstempel", ) _, self.timestamp = build_and_add_widget( timestamp_def, layout, widget_registry=self.widget_registry, ) self.timestamp.set_pydatetime(datetime.datetime.now(TIMEZONE_CEST)) contact_def = FormField[QLineEdit]( "Kontaktperson", FormFieldType.TEXT, placeholder="Freitext", required=True, key="ansprechpartner", ) _, self.contact = build_and_add_widget( contact_def, layout, widget_registry=self.widget_registry, ) contact_type_def = FormField[QComboBox]( "Kontakttyp", FormFieldType.DROPDOWN, options=[("Persönlich", None), ("Telefon", None), ("Mail", None)], required=True, key="kommunikationsweg", ) _, self.contact_type = build_and_add_widget( contact_type_def, layout, widget_registry=self.widget_registry, ) crm_link_def = FormField[QLineEdit]( "CRM-Verknüpfung", FormFieldType.TEXT, placeholder="CRM Links", required=True, key="thema_crm_matrix", ) _, self.crm_link = build_and_add_widget( crm_link_def, layout, widget_registry=self.widget_registry, ) plain_text_def = FormField[QPlainTextEdit]( "Anmerkungen", FormFieldType.LONGTEXT, key="anmerkungen", ) _, self.plain_text = build_and_add_widget( plain_text_def, layout, widget_registry=self.widget_registry, ) response_def = FormField[QPlainTextEdit]( "Rückmeldung", FormFieldType.LONGTEXT, key="rueckmeldung", ) _, self.response = build_and_add_widget( response_def, layout, widget_registry=self.widget_registry, ) self.btn_delete = QPushButton("✕") self.btn_delete.setFlat(True) # Entfernt den blockigen Rahmen self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;") self.btn_delete.clicked.connect(self._delete_request) self.btn_delete.setFixedWidth(30) layout.addWidget(self.btn_delete) widgets = ( self.user_field, self.timestamp, self.contact, self.contact_type, self.crm_link, self.plain_text, self.response, self.btn_delete, ) for widget in widgets: widget.setFixedHeight(ROW_HEIGHT) # layout.addWidget(widget) # column stretching: must be matched with header num_widgets = len(widgets) for i, widget in enumerate(widgets): stretch: int if i == (num_widgets - 1): stretch = 0 else: if "plaintext" in widget.__class__.__name__.lower(): stretch = 2 else: stretch = 1 layout.setStretch(i, stretch) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self._sync_state_to_GUI(None) def _delete_request(self) -> None: confirm = get_user_confirmation( self, "Löschen bestätigen", "Soll der Eintrag wirklich gelöscht werden?", ) if not confirm: return self.STATE.geloescht = True self.delete_request.emit(self) def _sync_state_to_GUI( self, section: Literal["values", "locking"] | None, ) -> None: logger_page_consulting.debug( "[Page Consulting -- Consulting Session]: State = %s", self.STATE ) if section is None or section == "values": set_widget_value(self.user_field, self.STATE.nutzer_name) if self.STATE.zeitstempel is not None: set_widget_value( self.timestamp, self.STATE.zeitstempel.astimezone(TIMEZONE_CEST) ) set_widget_value(self.contact, self.STATE.ansprechpartner) set_widget_value(self.contact_type, self.STATE.kommunikationsweg) set_widget_value(self.crm_link, self.STATE.thema_crm_matrix) set_widget_value(self.plain_text, self.STATE.anmerkungen) set_widget_value(self.response, self.STATE.rueckmeldung) if section is None or section == "locking": if self.STATE.locked: lock_form(self.widget_registry) self.btn_delete.setEnabled(False) else: unlock_form(self.widget_registry) self.btn_delete.setEnabled(True) def _sync_GUI_to_state(self) -> None: self.STATE.zeitstempel = self.timestamp.get_pydatetime() self.STATE.ansprechpartner = get_widget_value(self.contact) self.STATE.kommunikationsweg = get_widget_value(self.contact_type) self.STATE.thema_crm_matrix = get_widget_value( self.crm_link ) # TODO: change to correct link self.STATE.anmerkungen = get_widget_value(self.plain_text) self.STATE.rueckmeldung = get_widget_value(self.response) def get_state(self) -> Page_Consulting_ConsultingSession_State: self._sync_GUI_to_state() return self.STATE def validate(self) -> list[str]: logger_page_consulting.debug( ">>>>> Call validate on Session. Widget registry:\n%s", pformat_registry(self.widget_registry), ) return validate_form_data(self.widget_registry) def save_data(self) -> None: pass def load_state( self, new_state: Page_Consulting_ConsultingSession_State, ) -> None: set_page_state(self.STATE, new_state) self._sync_state_to_GUI(None) def lock(self) -> None: self.STATE.locked = True self._sync_state_to_GUI("locking") def unlock(self) -> None: self.STATE.locked = False self._sync_state_to_GUI("locking") class Page_Consulting_Table(QWidget): """child of 'Page_Consulting'""" def __init__( self, state: Page_Consulting_Table_State, ) -> None: super().__init__() self.STATE = state self.setWindowTitle("Native Dynamische Liste") self.resize(600, 400) main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 15, 0, 15) main_layout.setSpacing(10) # --- BUTTON (ADD) --- self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen") self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_add.setProperty("styleClass", "addButton") main_layout.addWidget(self.btn_add) self.btn_add.clicked.connect(lambda: self.add_row(None, False)) # --- HEADER --- header_widget = QWidget() header_layout = QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 5, 0, 5) header_layout.setSpacing(10) lbl_user_field = QLabel("Wer") lbl_timestamp = QLabel("Wann") lbl_contact = QLabel("Mit wem") lbl_contact_type = QLabel("Wie") lbl_crm_link = QLabel("Was") lbl_plain_text = QLabel("Anmerkungen") lbl_response = QLabel("Rückmeldung") lbl_action_delete = QLabel("") lbl_action_delete.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl_action_delete.setFixedWidth(30) labels = ( lbl_user_field, lbl_timestamp, lbl_contact, lbl_contact_type, lbl_crm_link, lbl_plain_text, lbl_response, lbl_action_delete, ) header_font = QFont() header_font.setBold(True) for lbl in labels: lbl.setFont(header_font) lbl.setStyleSheet("color: #666666;") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) header_layout.addWidget(lbl) plaintext_labels = {lbl_plain_text, lbl_response} num_labels = len(labels) for i, widget in enumerate(labels): stretch: int if i == (num_labels - 1): stretch = 0 else: if widget in plaintext_labels: stretch = 2 else: stretch = 1 header_layout.setStretch(i, stretch) main_layout.addWidget(header_widget) separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) separator.setFrameShadow(QFrame.Shadow.Sunken) main_layout.addWidget(separator) self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setFrameShape(QScrollArea.Shape.NoFrame) self.container_widget = QWidget() self.container_layout = QVBoxLayout(self.container_widget) self.container_layout.setContentsMargins(0, 5, 0, 5) self.container_layout.setSpacing(6) self.container_layout.addStretch() self.scroll_area.setWidget(self.container_widget) main_layout.addWidget(self.scroll_area) # --- STYLING (QSS) --- # self.setStyleSheet(""" # QWidget { # background-color: #1e1e24; # color: #e0e0e6; # font-family: 'Segoe UI', sans-serif; # font-size: 13px; # } # #HeaderWidget QLabel { # font-weight: bold; # color: #888892; # font-size: 11px; # letter-spacing: 1px; # } # QLineEdit { # background-color: #2a2a32; # border: 1px solid #3f3f4a; # border-radius: 6px; # padding: 6px 10px; # color: white; # } # QLineEdit:focus { # border: 1px solid #007acc; # } # QPushButton { # background-color: #007acc; # color: white; # border: none; # border-radius: 6px; # padding: 8px 15px; # font-weight: bold; # } # QPushButton:hover { # background-color: #0098ff; # } # #DeleteButton { # background-color: transparent; # color: #ff5555; # border: 1px solid #3f3f4a; # font-size: 14px; # padding: 4px; # } # #DeleteButton:hover { # background-color: #ff3333; # color: white; # border-color: #ff3333; # } # QScrollArea { # background-color: transparent; # } # """) def add_row( self, row_state: Page_Consulting_ConsultingSession_State | None, state_exists: bool, ) -> None: if row_state is None: row_state = Page_Consulting_ConsultingSession_State( session=self.STATE.session, beratung_id=None, nutzer_id=self.STATE.session.user_id, nutzer_name=self.STATE.session.user_name, locked=False, ) row = Page_Consulting_ConsultingSession(state=row_state) row.delete_request.connect(self.remove_row) index = self.container_layout.count() - 1 self.container_layout.insertWidget(index, row) self.STATE.child_modules.append(row) if not state_exists: self.STATE.row_states.append(row.get_state()) def remove_row( self, table_row_widget: Page_Consulting_ConsultingSession, ) -> None: self.container_layout.removeWidget(table_row_widget) self.STATE.child_modules.remove(table_row_widget) # do not remove the corresponding state since it is tracked # to be post-processed when the database saving operation is triggered table_row_widget.deleteLater() def remove_all_rows(self) -> None: while self.STATE.child_modules: self.remove_row(self.STATE.child_modules[0]) def _sync_state_to_GUI( self, section: Literal["rows", "locking"] | None, ) -> None: self.setUpdatesEnabled(False) if section is None or section == "rows": self.remove_all_rows() # states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht] # for row_state in states_to_show: for row_state in self.STATE.row_states: if not row_state.geloescht: self.add_row(row_state, True) if section is None or section == "locking": if self.STATE.locked: self.btn_add.setEnabled(False) for m in self.STATE.child_modules: m.lock() else: self.btn_add.setEnabled(True) for m in self.STATE.child_modules: m.unlock() self.setUpdatesEnabled(True) def _sync_GUI_to_state(self) -> None: logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state") logger_page_consulting.debug( ( "[Page -- Consulting] Table: Row data is managed when entries are added. " "There is no action needed. All states are obtained by reference and set " "by their row components/widgets. Therefore, this method calls the " "method `get_state` of all ACTIVE components to update their respective " "states with the current user inputs." ) ) logger_page_consulting.debug( "[Page -- Consulting] Table - Child modules: %s", pformat(self.STATE.child_modules), ) # Child modules are ACTIVE elements, viewable and changeable by the user. # The states are already part of this widget and linked by reference, but # the current user input must be synced with these states. Therefore, the # `get_state` method of these children is called, which implicitly updates # their state. The returned state reference is not needed. for row in self.STATE.child_modules: _ = row.get_state() def get_state(self) -> Page_Consulting_Table_State: logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...") self._sync_GUI_to_state() logger_page_consulting.debug("[Page Consulting -- Table]: State = %s", self.STATE) return self.STATE def load_state( self, new_state: Page_Consulting_Table_State, ) -> None: logger_page_consulting.debug( "[Page -- Consulting] Table: Call loading from state, %s", new_state ) set_page_state(self.STATE, new_state) self._sync_state_to_GUI(None) def validate(self) -> list[str]: errors: list[str] = [] for m in self.STATE.child_modules: errors.extend(m.validate()) return errors def save_data(self) -> None: pass def lock(self) -> None: self.STATE.locked = True self._sync_state_to_GUI("locking") def unlock(self) -> None: self.STATE.locked = False self._sync_state_to_GUI("locking") class InfoBanner(QFrame): btn_clicked = Signal() def __init__( self, text: str, button_text: str, parent: QWidget | None = None, ): super().__init__(parent) self.setStyleSheet(""" InfoBanner { background-color: #FFF3CD; border: 1px solid #FFEBAA; border-radius: 6px; } QLabel { color: #856404; font-size: 13px; } QPushButton { background-color: #856404; color: white; border-radius: 4px; padding: 4px 10px; font-weight: bold; } QPushButton:hover { background-color: #6C5204; } """) layout = QHBoxLayout(self) layout.setContentsMargins(15, 10, 15, 10) self.label = QLabel(text) layout.addWidget(self.label) layout.addStretch() if button_text: self.button = QPushButton(button_text) layout.addWidget(self.button) self.button.clicked.connect(self.btn_clicked.emit) # // test components #################################################################### def clear_layout( 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()) class _ModifyButton(QPushButton): modify_activated = Signal() modify_deactivated = Signal() def __init__( self, external_state_management: bool, txt_activate: str = "Bearbeitung aktivieren (Strg + B)", txt_deactivate: str = "Bearbeitung deaktivieren (Strg + B)", shortcut: str = "Ctrl+B", parent: QWidget | None = None, ) -> None: super().__init__(txt_activate, parent=parent) self.txt_activate = txt_activate self.txt_deactivate = txt_deactivate self.custom_shortcut: ( QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int ) = shortcut self.is_modify_state = False self.external_state_management = external_state_management self.setShortcut(self.custom_shortcut) self.clicked.connect(self._clicked) def set_modify_state( self, enable_modification: bool, ) -> None: if enable_modification: self.setText(self.txt_deactivate) else: self.setText(self.txt_activate) self.setShortcut(self.custom_shortcut) self.is_modify_state = enable_modification def _clicked(self) -> None: if self.is_modify_state: self.setText(self.txt_activate) self.modify_deactivated.emit() else: self.setText(self.txt_deactivate) self.modify_activated.emit() self.setShortcut(self.custom_shortcut) if not self.external_state_management: self.is_modify_state = not self.is_modify_state @override def setEnabled( self, enabled: bool, ) -> None: super().setEnabled(enabled) if enabled: self.setShortcut(self.custom_shortcut) @override def setShortcut( self, key: QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int, ) -> None: self.custom_shortcut = key super().setShortcut(key) class EditButtons(QWidget): def __init__( self, add_modify_btn: bool, add_reset_btn: bool, ) -> None: super().__init__() layout_btn = QHBoxLayout(self) layout_btn.setContentsMargins(0, 0, 0, 0) # modify self.modify_button: _ModifyButton | None = None if add_modify_btn: self.modify_button = _ModifyButton(external_state_management=True) self.modify_button.setFixedHeight(50) self.modify_button.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) layout_btn.addWidget(self.modify_button) # save self.save_btn_txt_enabled = "Speichern (Strg + S)" self.save_btn_txt_disabled = "Wird gespeichert..." self.save_btn = QPushButton(self.save_btn_txt_enabled) self.save_btn.setShortcut("Ctrl+S") self.save_btn.setFixedHeight(50) self.save_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout_btn.addWidget(self.save_btn) # reset self.reset_btn: QPushButton | None = None if add_reset_btn: self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") self.reset_btn.setShortcut("Ctrl+Z") self.reset_btn.setFixedHeight(50) self.reset_btn.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) layout_btn.addWidget(self.reset_btn) # delete self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") self.delete_btn.setShortcut("Ctrl+L") self.delete_btn.setFixedHeight(50) self.delete_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout_btn.addWidget(self.delete_btn) def set_modify_state( self, enable_modification: bool, set_delete_btn: bool = True, set_reset_btn: bool = True, ) -> None: if self.modify_button is not None: self.modify_button.set_modify_state(enable_modification) self.save_btn.setEnabled(enable_modification) if set_delete_btn: self.delete_btn.setEnabled(enable_modification) if self.reset_btn is not None and set_reset_btn: self.reset_btn.setEnabled(enable_modification) class MainWindow(QMainWindow): def __init__(self): super().__init__() # STATE MANAGEMENT session = Session(user_id=42, user_name="TEST-USER") self.STATE = Page_MainPage_State(session=session) self._history_stack: list[int] = [] self.setWindowTitle("Master") self.resize(1800, 1000) # 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_entry_select.new_consulting_requested.connect(self.show_page_consulting) self.new_entry_select.new_initrec_requested.connect(self.show_page_initrec) self.stack.addWidget(self.new_entry_select) # SITE: 'Grunderfassung' newinitrec_state = Page_InitRec_State( session=session, ids=EntityIds(), recording_type=RecordingType.WITH_COMPANY, form_data=Page_InitRec_Form_Data(metadata={}, person={}, company={}), locked=False, ) self.initrec = Page_InitRec(newinitrec_state) self.initrec.back_main_requested.connect(self.show_main_page) self.initrec.back_requested.connect(self.simple_go_back) self.initrec.company_profile_requested.connect(self.show_page_company_profile) self.initrec.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec) # SITE: 'Unternehmensprofil' comp_profile_state = Page_CompanyProfile_State(session=self.STATE.session) self.company_profile = Page_CompanyProfile(comp_profile_state) self.company_profile.back_main_requested.connect(self.show_main_page) # TODO remove # self.company_profile.back_requested.connect(self.show_page_initrec_company) self.company_profile.back_requested.connect(self.simple_go_back) self.company_profile.consulting_requested.connect(self.show_page_consulting) self.stack.addWidget(self.company_profile) # SITE: 'Beratungsgespräch' self.consulting_page = Page_Consulting(self.STATE.session) self.consulting_page.back_main_requested.connect(self.show_main_page) self.consulting_page.back_requested.connect(self.simple_go_back) self.consulting_page.update_triggered.connect(self.update_page_company_profile) self.stack.addWidget(self.consulting_page) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle 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.dt_updated.date().strftime(DATE_FMT) cell_date = MainPage_ClickableCell(cell_date_name, entry) cell_date.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_date, row, 4) self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen # TODO rework def goto_initial_recording( self, data: types.MainPageEntry, ): new_state = Page_InitRec_State( session=self.STATE.session, ids=data.ids, recording_type=data.recording_type, locked=True, ) self.initrec.load_state(new_state) self._navigate_to_target(self.initrec) 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_initrec( self, new_state: Page_InitRec_State, ): self.initrec.load_state(new_state) self._navigate_to_target(self.initrec) # 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: # enable static type checking for the protocol _m1: type[Module] = Page_Consulting _m2: type[Module] = Page_Consulting_Table _m3: type[Module] = Page_Consulting_ConsultingSession _m4: type[Module] = Page_InitRec _m5: type[Module] = InitRecForm _f1: type[CustomForm] = DynamicListWidget _f2: type[CustomForm] = DynamicDropdownWidgetNumeric _f3: type[CustomForm] = DynamicDropdownWidgetOption def main() -> None: sys.excepthook = global_exception_handler qInstallMessageHandler(qt_message_handler) try: app = QApplication(sys.argv) app.setStyleSheet(QSS) translator = QTranslator() translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) if translator.load(QLocale.system(), "qtbase", "_", translation_path): app.installTranslator(translator) window = MainWindow() window.show() sys.exit(app.exec()) except Exception as err: logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True) if __name__ == "__main__": main()