From e46e4945000567003931a510e7599082089667df Mon Sep 17 00:00:00 2001 From: foefl Date: Tue, 19 May 2026 16:16:14 +0200 Subject: [PATCH] Pydantic supported saving and loading prepared --- prototypes/t_qt_2.py | 1312 ++++++++++++++++++++++++------------------ prototypes/tests.py | 74 ++- 2 files changed, 821 insertions(+), 565 deletions(-) diff --git a/prototypes/t_qt_2.py b/prototypes/t_qt_2.py index ebe20ee..c322474 100644 --- a/prototypes/t_qt_2.py +++ b/prototypes/t_qt_2.py @@ -4,11 +4,13 @@ import copy import dataclasses as dc import datetime import enum +import pickle import re import sys import time import uuid from collections.abc import Sequence +from pathlib import Path from pprint import pprint from typing import Annotated, Any, Protocol, TypeAlias, TypedDict @@ -62,6 +64,33 @@ DROPDOWN_DEFAULT: str = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN = re.compile(r"-\[(\d+)\]") +def save_pydantic_model_dict( + model: BaseModel, + path: Path | None = None, +) -> None: + if path is None: + path = Path(__file__).parent / "model.pkl" + elif not path.is_file(): + raise ValueError("Path must point to a file") + + with open(path, "wb") as f: + pickle.dump(model.model_dump(), f) + + +def load_pydantic_model_dict( + path: Path | None = None, +) -> dict[str, Any]: + if path is None: + path = Path(__file__).parent / "model.pkl" + elif not path.is_file(): + raise ValueError("Path must point to a file") + + with open(path, "rb") as f: + model_dict = pickle.load(f) + + return model_dict + + @dc.dataclass(slots=True) class CountryList: iso_to_country: dict[str, str] @@ -110,9 +139,9 @@ def get_list_germany_states() -> CountryList: ] STATE_LIST.sort(key=lambda x: x[1]) - for iso_code, country_name in STATE_LIST: - states.append((country_name, iso_code)) - short_code_to_name[iso_code] = country_name + for country_name, code in STATE_LIST: + states.append((country_name, code)) + short_code_to_name[code] = country_name return CountryList( iso_to_country=short_code_to_name, @@ -121,7 +150,35 @@ def get_list_germany_states() -> CountryList: COUNTRY_LIST = get_country_list_german() -GERMAN_STATE_LIST = get_country_list_german() +GERMAN_STATE_LIST = get_list_germany_states() + + +def get_leafs(data): + if isinstance(data, dict): + for value in data.values(): + yield from get_leafs(value) + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from get_leafs(item) + else: + yield data + + +def get_leaf_dicts(data): + if isinstance(data, dict): + has_inner_dicts = False + + for value in data.values(): + for inner_dict in get_leaf_dicts(value): + has_inner_dicts = True + yield inner_dict + + if not has_inner_dicts: + yield data + + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from get_leaf_dicts(item) def pprint_registry(widget_registry: WidgetRegistry) -> None: @@ -133,10 +190,447 @@ def pprint_registry(widget_registry: WidgetRegistry) -> None: print(f"\tfield type: {entry['form_field'].type}") +class CustomForm(Protocol): + def get_form_data(self) -> dict[str, Any]: ... + + def reset_form(self) -> None: ... + + +def _build_ui_recursively( + schema: Sequence[FormField], + parent_layout: QFormLayout, + widget_registry: WidgetRegistry, + prefix: str = "", +) -> None: + for field in schema: + full_key = f"{prefix}.{field.key}" if prefix else field.key + widget: QWidget + + 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 FormFieldType.TEXT: + widget = QLineEdit() + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.fill_value: + widget.setText(field.fill_value) + if field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + case FormFieldType.LONGTEXT: + widget = QPlainTextEdit() + widget.setMaximumHeight(80) + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.readonly: + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + 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 field.fill_value: + set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{field.fill_value}<" + ) + widget.setDate(set_date) + else: + widget.setDate(QDate.currentDate()) + + if field.readonly: + widget = QLineEdit() + widget.setReadOnly(True) + widget.setProperty("styleClass", "stempel") + if field.fill_value: + widget.setText(field.fill_value) + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + case FormFieldType.DROPDOWN: + widget = QComboBox() + assert field.dropdown_options + widget.addItem(DROPDOWN_DEFAULT, None) + for option in field.dropdown_options: + widget.addItem(option.label, option.data) + + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.fill_value: + widget.setCurrentText(field.fill_value) + else: + widget.setCurrentIndex(0) + if field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + 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) + + assert field.dropdown_options + widget.addItem(DROPDOWN_DEFAULT, None) + for option in field.dropdown_options: + widget.addItem(option.label, option.data) + + if field.placeholder: + line_edit = widget.lineEdit() + assert line_edit + line_edit.setPlaceholderText(field.placeholder) + if field.fill_value: + widget.setCurrentText(field.fill_value) + else: + widget.setCurrentIndex(-1) + if field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + + if field.tooltip: + tooltip_layout = _add_tooltip(widget, field.tooltip) + parent_layout.addRow(field.label, tooltip_layout) + else: + parent_layout.addRow(field.label, widget) + + case FormFieldType.DYNAMIC_LIST: + widget = DynamicListWidget( + field.children, + field.label, + prefix=f"{full_key}", + ) + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + parent_layout.addRow(widget) + + case FormFieldType.DYNAMIC_DROPDOWN: + widget = DynamicDropdownWidget( + field.children, + field.label, + prefix=f"{full_key}", + ) + widget_registry[full_key] = { + "widget": widget, + "form_field": field, + } + parent_layout.addRow(widget) + + case _: + raise NotImplementedError(f"Not supported field type: {field.type.value}") + + +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"] + + 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, QComboBox): + if form_field.fill_value: + widget.setCurrentText(form_field.fill_value) + else: + widget.setCurrentIndex(0) + elif isinstance(widget, DynamicListWidget): + # dynamic list widget manages its widgets by itself + widget.reset_form() + elif isinstance(widget, DynamicDropdownWidget): + # dynamic list widget manages its widgets by itself + widget.reset_form() + + widget.setStyleSheet("") + + +def _insert_nested( + target_dict: dict[str, Any], + key_path: Sequence[str], + value: Any, +): + # keys 'a.b.c = 1' --> {'a': {'b': {'c': 1}}} + for part in key_path[:-1]: + target_dict = target_dict.setdefault(part, {}) + target_dict[key_path[-1]] = value + + +def _get_nested( + target_dict: dict[str, Any], + key_path: Sequence[str], +) -> Any: + # keys 'a.b.c = 1' --> {'a': {'b': {'c': 1}}} + current = target_dict + for part in key_path: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None # path does not exist + + return current + + +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, +) -> dict[str, Any]: + raw_data = {} + for key, registry_entry in widget_registry.items(): + value: Any | None = None + + widget = registry_entry["widget"] + if isinstance(widget, QLineEdit): + data = widget.text() + if data != "": + value = data + 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, QComboBox): + value = widget.currentData() + elif isinstance(widget, DynamicListWidget): + # this should be a list: each dynamic list contains a list + # of such dictionaries + form_data = widget.get_form_data() + value = [val for val in form_data.values()] + # print(">>>>>>>>> Key: ", key) + # print(">>>>>>>>> Form Data:") + # pprint(form_data) + elif isinstance(widget, DynamicDropdownWidget): + # this is a special data structure with some assumptions of the widget's structure + form_data = widget.get_form_data() + value = form_data + # value = [val for val in form_data.values()] + # print(">>>>>>>>> Key: ", key) + # print(">>>>>>>>> Form Data after call:") + # pprint(form_data) + + _insert_nested(raw_data, key.split("."), value) + + return raw_data + + +def validate_form_data( + widget_registry: WidgetRegistry, +) -> list[str]: + errors: list[str] = [] + + for key, registry_entry in widget_registry.items(): + error_post: 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, DynamicListWidget): + errors_widget = widget.validate_form_data() + 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 + widget.setStyleSheet(""" + border: 1px solid #ef4444; + background-color: #ffe9e9; + padding: 4px; + border-radius: 4px; + """) + + return errors + + class Grunderfassung_Unternehmen(BaseModel): - Projektrelevanz: Grunderfassung_Projektrelevanz - Kontaktperson: Grunderfassung_Kontaktperson - Stammdaten: Grunderfassung_Stammdaten + Projektrelevanz: Grunderfassung_Projektrelevanz | None = None + Kontaktperson: Grunderfassung_Kontaktperson | None = None + Stammdaten: Grunderfassung_Stammdaten | None = None + Schulbildung: list[Grunderfassung_Schulbildung] | None = None + + +class Grunderfassung_Schulbildung(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + SB_abschluss: str | None + SB_abschlussgrad: str | None + SB_schule: str | None + SB_ort: str | None + SB_land: str | None + SB_abschlussjahr: str | None + SB_bemerkungsfeld: str | None class Grunderfassung_Projektrelevanz(BaseModel): @@ -200,8 +694,7 @@ class Grunderfassung_Stammdaten(BaseModel): Stammdaten_mobilfunknummer: str | None Stammdaten_email: EmailStr | None Stammdaten_familienstand: str | None - Stammdaten_anzahl_kinder: int | None - Stammdaten_alter_kinder: list[ValidAge] = Field(default_factory=list) + Stammdaten_anzahl_kinder: Grunderfassung_Stammdaten_AnzahlKinder @field_validator("Stammdaten_rueckkehrer", mode="before") @classmethod @@ -219,6 +712,13 @@ class Grunderfassung_Stammdaten(BaseModel): return value +class Grunderfassung_Stammdaten_AnzahlKinder(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + anzahl: int | None + alter: list[ValidAge | None] | None = None + + class CompanyForm_Search(QWidget): company_selected = Signal(int) @@ -532,105 +1032,21 @@ class WidgetRegistryEntry(TypedDict): WidgetRegistry: TypeAlias = dict[str, WidgetRegistryEntry] -# FORM_FIELD_DEF = [ -# FormField( -# "test", -# FormFieldType.DROPDOWN, -# required=False, -# options=["test1", "test2"], -# ), -# FormField( -# "test", -# FormFieldType.DROPDOWN, -# required=False, -# options=["test1", "test2"], -# fill_value="test1", -# ), -# FormField( -# "test", -# FormFieldType.DROPDOWN, -# required=False, -# options=["test1", "test2"], -# fill_value="test2", -# readonly=True, -# ), -# FormField( -# "test", -# FormFieldType.DROPDOWN, -# required=False, -# options=["test1", "test2", "test3"], -# fill_value="test3", -# ), -# FormField("Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), -# FormField("Beschreibung", FormFieldType.LONGTEXT, False), -# FormField( -# "Externe Daten", -# FormFieldType.TEXT, -# True, -# fill_value="Lorem ipsum und so weiter...", -# readonly=True, -# ), -# FormField("Auftragsdatum", FormFieldType.DATE, True), -# FormField("Startdatum", FormFieldType.DATE, True), -# FormField( -# "MS Datum 1", -# FormFieldType.DATE, -# False, -# "", -# fill_value="26.07.2026", -# readonly=True, -# ), -# FormField( -# "MS Datum 2", -# FormFieldType.DATE, -# False, -# "", -# fill_value="30.08.2026", -# readonly=False, -# ), -# FormField( -# "Wichtige Notizen", -# FormFieldType.LONGTEXT, -# True, -# "Text eingeben...", -# ), -# ] +def search_widget_by_key( + widget_registry: WidgetRegistry, + key_part: str, +) -> QWidget | None: + """ + needed for custom logic of auto-built forms, + search for specific keys and obtain the widget to assign + special logic or callbacks to them + """ + hit: QWidget | None = None + for key, entry in widget_registry.items(): + if key_part in key: + hit = entry["widget"] -# FORM_FIELD_DEF2 = [ -# FormField("Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), -# FormField("Beschreibung", FormFieldType.LONGTEXT, False), -# FormField( -# "Externe Daten", -# FormFieldType.TEXT, -# True, -# fill_value="Lorem ipsum und so weiter...", -# readonly=True, -# ), -# FormField("Auftragsdatum", FormFieldType.DATE, True), -# FormField("Startdatum", FormFieldType.DATE, True), -# FormField( -# "MS Datum 1", -# FormFieldType.DATE, -# False, -# "", -# fill_value="26.07.2026", -# readonly=True, -# ), -# FormField( -# "MS Datum 2", -# FormFieldType.DATE, -# False, -# "", -# fill_value="30.08.2026", -# readonly=False, -# ), -# FormField( -# "Wichtige Notizen", -# FormFieldType.LONGTEXT, -# True, -# "Text eingeben...", -# ), -# ] + return hit FORM_FIELDS_CONTACT_PERSON = [ @@ -753,7 +1169,7 @@ FORM_FIELDS_MASTER_DATA = [ FormField( "Staatsangehörigkeit", FormFieldType.EXTENDED_DROPDOWN, - key="Stammdaten_herkunftsland", + key="Stammdaten_staatsangehoerigkeit", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, @@ -811,9 +1227,11 @@ FORM_FIELDS_MASTER_DATA = [ ), FormField( "Land", - FormFieldType.TEXT, + FormFieldType.EXTENDED_DROPDOWN, key="Stammdaten_land", required=False, + placeholder="Suche...", + options=COUNTRY_LIST.for_dropdown, ), FormField( "Festnetznummer", @@ -861,11 +1279,9 @@ FORM_FIELDS_MASTER_DATA = [ required=False, options=[(str(x), None) for x in range(11)], tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", - key="Stammdaten_anzahl_kinder", + key="anzahl", children=[ - FormField( - "Alter Kind", FormFieldType.TEXT, key="Stammdaten_alter_kinder" - ), + FormField("Alter Kind", FormFieldType.TEXT, key="alter"), ], ), ], @@ -942,36 +1358,39 @@ FORM_FIELDS_ADDITIONAL_DATA = [ ] FORM_FIELDS_SCHOOL = [ - FormField("Abschluss", FormFieldType.TEXT, required=False, key="abschluss"), + FormField("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"), FormField( - "Abschlussgrad laut Dokument", FormFieldType.TEXT, required=False, key="abschlussgrad" + "Abschlussgrad laut Dokument", + FormFieldType.TEXT, + required=False, + key="SB_abschlussgrad", ), FormField( "Schule", FormFieldType.TEXT, required=False, - key="schule", + key="SB_schule", ), FormField( "Ort", FormFieldType.TEXT, required=False, - key="ort", + key="SB_ort", ), FormField( "Land", FormFieldType.EXTENDED_DROPDOWN, - key="country", + key="SB_land", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, ), - FormField("Abschlussjahr", FormFieldType.TEXT, required=False, key="abschlussjahr"), + FormField("Abschlussjahr", FormFieldType.TEXT, required=False, key="SB_abschlussjahr"), FormField( "Bemerkungsfeld", FormFieldType.TEXT, required=False, - key="bemerkung", + key="SB_bemerkungsfeld", ), ] @@ -1142,26 +1561,26 @@ FORM_FIELDS_LANGUAGES = [ FORM_FIELDS = [ - FormField( - "Status && Projektrelevanz", - FormFieldType.GROUP, - key="Projektrelevanz", - children=[ - FormField( - "Projektrelevanz", - FormFieldType.DROPDOWN, - key="Projektrelevanz_relevanz", - required=True, - options=[("ja", None), ("nein", None)], - ), - ], - ), - FormField( - "Daten Kontaktperson", - FormFieldType.GROUP, - key="Kontaktperson", - children=FORM_FIELDS_CONTACT_PERSON, - ), + # FormField( + # "Status && Projektrelevanz", + # FormFieldType.GROUP, + # key="Projektrelevanz", + # children=[ + # FormField( + # "Projektrelevanz", + # FormFieldType.DROPDOWN, + # key="Projektrelevanz_relevanz", + # required=True, + # options=[("ja", None), ("nein", None)], + # ), + # ], + # ), + # FormField( + # "Daten Kontaktperson", + # FormFieldType.GROUP, + # key="Kontaktperson", + # children=FORM_FIELDS_CONTACT_PERSON, + # ), FormField( "Stammdaten", FormFieldType.GROUP, @@ -1222,409 +1641,6 @@ FORM_FIELDS = [ ] -class CustomForm(Protocol): - def get_form_data(self) -> dict[str, Any]: ... - - def reset_form(self) -> None: ... - - -def _build_ui_recursively( - schema: Sequence[FormField], - parent_layout: QFormLayout, - widget_registry: WidgetRegistry, - prefix: str = "", -) -> None: - for field in schema: - full_key = f"{prefix}.{field.key}" if prefix else field.key - widget: QWidget - - 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 FormFieldType.TEXT: - widget = QLineEdit() - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setText(field.fill_value) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.LONGTEXT: - widget = QPlainTextEdit() - widget.setMaximumHeight(80) - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.readonly: - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - 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 field.fill_value: - set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") - if not set_date.isValid(): - raise ValueError( - f"Could not parse date field value >{field.fill_value}<" - ) - widget.setDate(set_date) - else: - widget.setDate(QDate.currentDate()) - - if field.readonly: - widget = QLineEdit() - widget.setReadOnly(True) - widget.setProperty("styleClass", "stempel") - if field.fill_value: - widget.setText(field.fill_value) - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DROPDOWN: - widget = QComboBox() - assert field.dropdown_options - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - widget.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(0) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - 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) - - assert field.dropdown_options - widget.addItem(DROPDOWN_DEFAULT, None) - for option in field.dropdown_options: - widget.addItem(option.label, option.data) - - if field.placeholder: - line_edit = widget.lineEdit() - assert line_edit - line_edit.setPlaceholderText(field.placeholder) - if field.fill_value: - widget.setCurrentText(field.fill_value) - else: - widget.setCurrentIndex(-1) - if field.readonly: - widget.setEnabled(False) - widget.setProperty("styleClass", "stempel") - - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - - if field.tooltip: - tooltip_layout = _add_tooltip(widget, field.tooltip) - parent_layout.addRow(field.label, tooltip_layout) - else: - parent_layout.addRow(field.label, widget) - - case FormFieldType.DYNAMIC_LIST: - widget = DynamicListWidget( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case FormFieldType.DYNAMIC_DROPDOWN: - widget = DynamicDropdownWidget( - field.children, - field.label, - prefix=f"{full_key}", - ) - widget_registry[full_key] = { - "widget": widget, - "form_field": field, - } - parent_layout.addRow(widget) - - case _: - raise NotImplementedError(f"Not supported field type: {field.type.value}") - - -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"] - - 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, QComboBox): - if form_field.fill_value: - widget.setCurrentText(form_field.fill_value) - else: - widget.setCurrentIndex(0) - elif isinstance(widget, DynamicListWidget): - # dynamic list widget manages its widgets by itself - widget.reset_form() - elif isinstance(widget, DynamicDropdownWidget): - # dynamic list widget manages its widgets by itself - widget.reset_form() - - widget.setStyleSheet("") - - -def _insert_nested( - target_dict: dict[str, Any], - key_path: Sequence[str], - value: Any, -): - # keys 'a.b.c = 1' --> {'a': {'b': {'c': 1}}} - for part in key_path[:-1]: - target_dict = target_dict.setdefault(part, {}) - target_dict[key_path[-1]] = value - - -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, -) -> dict[str, Any]: - raw_data = {} - for key, registry_entry in widget_registry.items(): - value: Any | None = None - - widget = registry_entry["widget"] - if isinstance(widget, QLineEdit): - value = widget.text() - elif isinstance(widget, QPlainTextEdit): - value = widget.toPlainText() - elif isinstance(widget, QDateEdit): - qt_date = widget.date() - value = qt_date.toPython() - elif isinstance(widget, QComboBox): - value = widget.currentData() - elif isinstance(widget, DynamicListWidget): - # this should be a list: each dynamic list contains a list - # of such dictionaries - form_data = widget.get_form_data() - value = [val for val in form_data.values()] - # print(">>>>>>>>> Form Data:") - # pprint(form_data) - elif isinstance(widget, DynamicDropdownWidget): - # this should be a list: each dynamic list contains a list - # of such dictionaries - form_data = widget.get_form_data() - value = [val for val in form_data.values()] - # print(">>>>>>>>> Form Data:") - # pprint(form_data) - - _insert_nested(raw_data, key.split("."), value) - - return raw_data - - -def validate_form_data( - widget_registry: WidgetRegistry, -) -> list[str]: - errors: list[str] = [] - - for key, registry_entry in widget_registry.items(): - error_post: 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, DynamicListWidget): - errors_widget = widget.validate_form_data() - 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 - widget.setStyleSheet(""" - border: 1px solid #ef4444; - background-color: #ffe9e9; - padding: 4px; - border-radius: 4px; - """) - - return errors - - class AutoForm(QWidget): def __init__( self, @@ -1703,6 +1719,14 @@ class AutoForm(QWidget): self.reset_btn.clicked.connect(self.reset_form) self.layout_btn.addWidget(self.reset_btn) + # test button + self.main_layout.addSpacing(10) + self.test_button = QPushButton("Initialisiere Laden") + self.test_button.clicked.connect(self.on_load_clicked) + self.test_button.setFixedHeight(50) + self.test_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.main_layout.addWidget(self.test_button) + def _disable_save(self) -> None: self.save_btn.setEnabled(False) self.save_btn.setText(self.save_btn_txt_disabled) @@ -1715,6 +1739,61 @@ class AutoForm(QWidget): QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) self.save_btn.setText(self.save_btn_txt_enabled) + def on_load_clicked(self) -> None: + print(">>>> LOAD CLICKED") + loaded_data = load_pydantic_model_dict() + # print("Loaded dictionary:") + # pprint(loaded) + + # print(">>>> Widget registry:") + # pprint_registry(self.widget_registry) + + for key, entry in self.widget_registry.items(): + widget = entry["widget"] + + # get data from dictionary + key_path = key.split(".") + value = _get_nested(loaded_data, key_path) + + # print(f"Key: {key}") + # print(f"Value: {value}") + + # Wenn kein Wert da ist (z.B. weil er optional ist), überspringen wir das Feld + if value is None: + continue + elif value is True: + value = "ja" + elif value is False: + value = "nein" + + # 2. Das Widget entsprechend seinem Typ füllen + if isinstance(widget, QLineEdit): + widget.setText(str(value)) # str() zur Sicherheit, falls es ein int ist + + elif isinstance(widget, QComboBox): + # MEGA WICHTIG: Wir suchen nicht den Text, sondern die unsichtbaren Daten! + # (z.B. suchen wir nach dem ISO-Code 'DE' oder der ID 1042) + index = widget.findData(value) + if index >= 0: + widget.setCurrentIndex(index) + + elif isinstance(widget, (DynamicListWidget, DynamicDropdownWidget)): + print("\n-----------------\n\nCustom widget, skip...") + print(f"Key: {key}") + print("Widget type: ", type(widget).__name__) + print("current value or data format: ") + pprint(value) + + if hasattr(widget, "set_form_data"): + widget.set_form_data(value) # type: ignore + + continue + + # elif hasattr(widget, "set_data"): + # # Wenn es eins unserer Custom-Widgets ist (Dropdown-Liste, Dynamische Liste) + # # übergeben wir die Liste einfach an das Widget selbst! + # widget.set_data(wert) + def on_save_clicked(self) -> None: self._disable_save() errors = validate_form_data(self.widget_registry) @@ -1734,23 +1813,53 @@ class AutoForm(QWidget): print("Erfolg! Alle Daten sind valide.") print("Get form data call...") form_data = self.get_form_data() - post_proc_1 = form_data["Stammdaten"]["Stammdaten_anzahl_kinder"] + # post_proc_1 = form_data["Stammdaten"]["Stammdaten_anzahl_kinder"] - Stammdaten_anzahl_kinder = post_proc_1[0]["Stammdaten_anzahl_kinder-[0]"][ - "'Stammdaten_anzahl_kinder'" - ] - Stammdaten_alter_kinder: list[str] = [] - if len(post_proc_1) > 1: - for i in range(1, len(post_proc_1)): - content = post_proc_1[i] + # Stammdaten_anzahl_kinder = post_proc_1[0]["Stammdaten_anzahl_kinder-[0]"][ + # "'Stammdaten_anzahl_kinder'" + # ] + # Stammdaten_alter_kinder: list[str] = [] + # if len(post_proc_1) > 1: + # for i in range(1, len(post_proc_1)): + # content = post_proc_1[i] print("------------>>>>>>>>> Get form data") pprint(form_data) - # ------------------------------------------------------------ - # !! keep this code must be called again - self.reset_form() - self._enable_save() + print("------------>>>>>>>>> Call Pydantic") + try: + validated_data = Grunderfassung_Unternehmen(**form_data) + # validated_data = Grunderfassung_Stammdaten(**form_data["Stammdaten"]) + pprint(validated_data.model_dump()) + except ValidationError as e: + # 4. Fehler abfangen und in der GUI anzeigen! + fehler_texte = [] + + # Pydantic liefert eine detaillierte Fehlerliste + for error in e.errors(): + print(error) + + # error['loc'][0] enthält den Namen des Feldes (z.B. 'email') + fehlerhaftes_feld = str(error["loc"][0]) + grund = error["msg"] + + fehler_texte.append(f"- {fehlerhaftes_feld}: {grund}") + + # Wir machen das betroffene Widget in PySide rot! + # if fehlerhaftes_feld in self.widgets: + # self.widgets[fehlerhaftes_feld].setStyleSheet( + # "border: 1px solid red; background: #fef2f2;" + # ) + + # Dem Nutzer gesammelt mitteilen, was schiefgelaufen ist + QMessageBox.warning(self, "Eingabefehler", "\n".join(fehler_texte)) + else: + # !! keep this code must be called again + self.reset_form() + save_pydantic_model_dict(validated_data) + finally: + self._enable_save() + # ------------------------------------------------------------ def reset_form(self) -> None: reset_form(self.widget_registry) @@ -1901,6 +2010,28 @@ class DynamicListWidget(QWidget): return raw_data + def set_form_data( + self, + data: list[dict[str, Any]], + ) -> None: + + while self.sub_forms: + self.remove_entry(self.sub_forms[0]) + + # empyt default row + if not data: + self.add_entry() + return + + # TODO rework + for entry in data: + self.add_entry() + + # get all relevant widgets and fill in the values + # current_sub_form = self.sub_forms[-1] + + # fill in values + class DynamicDropdownWidget(QWidget): """ @@ -2038,8 +2169,77 @@ class DynamicDropdownWidget(QWidget): def get_form_data(self): raw_data = get_form_data(self.widget_registry) + # each sub form has its own numbered key: We need to get rid of these + # This widget has one first dropdown value and then an arbitrary number of + # list values + # The following procedure assumes that the data is extracted as one + # dropdown value and a list of associated values in the form of the following schema + # data = {"key_dropdown": dropdown_value, "key_child_field": list[child_values] | None} - return raw_data + # print(">>>> Dynamic dropdown: ") + # pprint(raw_data) + + export_dict: dict[str, Any] = {} + children_values: list[str] | None = None + + for idx, data_dict in enumerate(get_leaf_dicts(raw_data)): + if idx == 0: + export_dict.update(data_dict) + else: + for key in data_dict: + if key not in export_dict: + children_values = export_dict.setdefault(key, []) + assert children_values is not None + children_values.append(data_dict[key]) + + return export_dict + + def set_form_data( + self, + data: dict[str, Any], + ) -> None: + # delete all rows + while self.sub_forms: + self._remove_row() + + # empty default line + # !! only dynamic list widget + # if not daten_liste: + # self._add_row() + # return + + # fill in value of combobox field + entries = tuple(self.widget_registry.values()) + assert len(entries) == 1 + widget = entries[0]["widget"] + assert isinstance(widget, QComboBox) + value = data["anzahl"] + index = widget.findData(value) + if index >= 0: + widget.setCurrentIndex(index) + + data_list: list[int | None] | None = data["alter"] + if not data_list: + return + # now there are as many new sub forms as the saved value for the dropdown + # assert this + assert len(data_list) == len(self.sub_forms) + + print(">>>>>>> New Registry of dynamic dropdown:") + pprint_registry(self.widget_registry) + + sub_form_index: int = 0 + for key, entry in self.widget_registry.items(): + if "-[0]" in key: + # this the first widget assigned which is the dropdown field + continue + widget = entry["widget"] + value = data_list[sub_form_index] + if value is None: + value = "" + if isinstance(widget, QLineEdit): + widget.setText(str(value)) + sub_form_index += 1 class ClickableCell(QFrame): diff --git a/prototypes/tests.py b/prototypes/tests.py index 135f0ae..eed7b4f 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -6,6 +6,7 @@ from collections.abc import Sequence from typing import Any import babel +from pydantic import BaseModel from PySide6.QtCore import QDate, Qt # %% @@ -52,21 +53,76 @@ new_content = { } } +new_content = { + "Stammdaten": { + "Stammdaten_anzahl_kinder-[0]": {"Stammdaten_anzahl_kinder": "5"}, + "Stammdaten_anzahl_kinder-[1]": {"Stammdaten_alter_kinder": None}, + "Stammdaten_anzahl_kinder-[2]": {"Stammdaten_alter_kinder": None}, + "Stammdaten_anzahl_kinder-[3]": {"Stammdaten_alter_kinder": None}, + "Stammdaten_anzahl_kinder-[4]": {"Stammdaten_alter_kinder": None}, + "Stammdaten_anzahl_kinder-[5]": {"Stammdaten_alter_kinder": None}, + } +} +# object Stammdaten_Anzahl_Kinder: Stammdaten_anzahl_kinder: int, Stammdaten_alter_kinder: list[int] -def flat_dict(contents): - for x in contents: - if isinstance(contents, dict): - yield from flat_dict(tuple(contents[x])) - elif isinstance(x, (list, tuple, set)): - yield from flat_dict(x) - else: - yield x + +def get_leafs(data): + if isinstance(data, dict): + for value in data.values(): + yield from get_leafs(value) + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from get_leafs(item) + else: + yield data + + +def get_leaf_dicts(data): + if isinstance(data, dict): + has_inner_dicts = False + + for value in data.values(): + for inner_dict in get_leaf_dicts(value): + has_inner_dicts = True + yield inner_dict + + if not has_inner_dicts: + yield data + + elif isinstance(data, (list, tuple, set)): + for item in data: + yield from get_leaf_dicts(item) # %% -for x in flat_dict(new_content): +for x in get_leafs(new_content): print(x) +# %% +export_dict = {} +children_values: list[str] | None = None +for idx, data_dict in enumerate(get_leaf_dicts(new_content)): + if idx == 0: + export_dict.update(data_dict) + else: + for key in data_dict: + if key not in export_dict: + children_values = export_dict.setdefault(key, []) + assert children_values is not None + children_values.append(data_dict[key]) + + +export_dict + + +# %% +class Stammdaten_AnzahlKinder(BaseModel): + Stammdaten_anzahl_kinder: int | None + Stammdaten_alter_kinder: list[int | None] + + +# %% +Stammdaten_AnzahlKinder(**export_dict) # %% find_dynamic_content(dynamic_content)