From 3dbc9ecfcbab1a8c8b197bac5e173f27cb5f6cae Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 30 Apr 2026 14:51:35 +0200 Subject: [PATCH] dataclass-driven form generation --- prototypes/t_qt_2.py | 538 ++++++++++++++++++++++++++++--------------- 1 file changed, 349 insertions(+), 189 deletions(-) diff --git a/prototypes/t_qt_2.py b/prototypes/t_qt_2.py index d670ebb..7d7fad7 100644 --- a/prototypes/t_qt_2.py +++ b/prototypes/t_qt_2.py @@ -4,6 +4,7 @@ import dataclasses as dc import enum import sys import time +import uuid from collections.abc import Sequence from PySide6.QtCore import QDate, Qt, QTimer, Signal # Signal ist wichtig! @@ -169,6 +170,8 @@ class AddressForm_Search(QWidget): form_layout.addRow("Suche:", self.search_input) # search_data = [addr.name for addr in ADDRESSES] # self.SEARCH_MAP = {addr.name: addr for addr in ADDRESSES} + # TODO Qt supports the addition of custom data like addItem + Qt.UserRole + # TODO or setProperty self.SEARCH_MAP = be_init_rec.comp_search_choice_mapping() self.search_data = tuple(self.SEARCH_MAP.keys()) self.completer = QCompleter(self.search_data) @@ -304,48 +307,82 @@ class FormFieldType(enum.StrEnum): LONGTEXT = enum.auto() DATE = enum.auto() DATETIME = enum.auto() + DROPDOWN = enum.auto() @dc.dataclass(slots=True) class FormField: - key: str label: str type: FormFieldType required: bool placeholder: str | None = None fill_value: str | None = None readonly: bool = False + options: Sequence[str] | None = None + key: str = "" + tooltip: str = "" def __post_init__(self) -> None: + if not self.key: + self.key = str(uuid.uuid4()) + self.label = self.label.strip() if not self.label.endswith(":"): self.label += ":" if self.required: self.label += "*" + if self.type is FormFieldType.DROPDOWN and self.options is None: + raise ValueError("Invalid field definition: Dropdown requires options") + @dc.dataclass(slots=True) class FormFieldGroup: - key: str - label: str + label: str | None fields: Sequence[FormField] FORM_FIELD_DEF = [ - FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), - FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False), FormField( - "ext_data", + "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("init_date", "Auftragsdatum", FormFieldType.DATE, True), - FormField("start_date", "Startdatum", FormFieldType.DATE, True), + FormField("Auftragsdatum", FormFieldType.DATE, True), + FormField("Startdatum", FormFieldType.DATE, True), FormField( - "ms_date1", "MS Datum 1", FormFieldType.DATE, False, @@ -354,7 +391,6 @@ FORM_FIELD_DEF = [ readonly=True, ), FormField( - "ms_date2", "MS Datum 2", FormFieldType.DATE, False, @@ -363,7 +399,6 @@ FORM_FIELD_DEF = [ readonly=False, ), FormField( - "important:notes", "Wichtige Notizen", FormFieldType.LONGTEXT, True, @@ -372,20 +407,18 @@ FORM_FIELD_DEF = [ ] FORM_FIELD_DEF2 = [ - FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), - FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False), + FormField("Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), + FormField("Beschreibung", FormFieldType.LONGTEXT, False), FormField( - "ext_data", "Externe Daten", FormFieldType.TEXT, True, fill_value="Lorem ipsum und so weiter...", readonly=True, ), - FormField("init_date", "Auftragsdatum", FormFieldType.DATE, True), - FormField("start_date", "Startdatum", FormFieldType.DATE, True), + FormField("Auftragsdatum", FormFieldType.DATE, True), + FormField("Startdatum", FormFieldType.DATE, True), FormField( - "ms_date1", "MS Datum 1", FormFieldType.DATE, False, @@ -394,7 +427,6 @@ FORM_FIELD_DEF2 = [ readonly=True, ), FormField( - "ms_date2", "MS Datum 2", FormFieldType.DATE, False, @@ -403,7 +435,6 @@ FORM_FIELD_DEF2 = [ readonly=False, ), FormField( - "important:notes", "Wichtige Notizen", FormFieldType.LONGTEXT, True, @@ -411,60 +442,89 @@ FORM_FIELD_DEF2 = [ ), ] + +FORM_FIELDS_CONTACT_PERSON = [ + FormField( + "Name Unternehmen/Netzwerkpartner (pre-filled von Suche)", + FormFieldType.TEXT, + required=False, + placeholder="Text wird nach gewähltem Unternehmen angezeigt", + readonly=True, + ), + FormField( + "Titel", + FormFieldType.TEXT, + required=False, + tooltip=( + "* nur wenn anrufende Person oder kontaktaufnehmende Person " + "nicht die zu beratende Person ist" + ), + ), + FormField( + "Anrede_Anschrift", + FormFieldType.TEXT, + required=True, + ), + FormField( + "Name", + FormFieldType.TEXT, + required=True, + ), + FormField( + "Vorname", + FormFieldType.TEXT, + required=False, + ), + FormField( + "Festnetznummer", + FormFieldType.TEXT, + required=False, + ), + FormField( + "Mobilfunknummer", + FormFieldType.TEXT, + required=False, + ), + FormField( + "E-Mail", + FormFieldType.TEXT, + required=False, + ), + FormField( + "Funktion/Beziehung zur beratenden Person", + FormFieldType.TEXT, + required=False, + ), + FormField( + "Adresse", + FormFieldType.LONGTEXT, + required=False, + ), +] + FORM_FIELD_GROUPS = [ - FormFieldGroup("group1", "Test-1", FORM_FIELD_DEF), - FormFieldGroup("group2", "Test-2", FORM_FIELD_DEF2), + FormFieldGroup( + "Status && Projektrelevanz", + [ + FormField( + "Projektrelevanz", + FormFieldType.DROPDOWN, + required=True, + options=["ja", "nein"], + ), + ], + ), + FormFieldGroup("Daten Kontaktperson", FORM_FIELDS_CONTACT_PERSON), + # FormFieldGroup("Test-2", FORM_FIELD_DEF2), ] -class MyForm(QWidget): +class AutoForm(QWidget): def __init__( self, form_field_groups: Sequence[FormFieldGroup], add_buttons: bool = True, ) -> None: - super().__init__() - # --- LAYOUT --- - self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 0, 0, 0) - self.form_field_groups = form_field_groups - - for fg in form_field_groups: - widget = MyFormPart(fg.fields, fg.label, add_buttons=False) - self.main_layout.addWidget(widget) - - # buttons - # self.add_buttons = add_buttons - # if self.add_buttons: - # self.layout_btn = QHBoxLayout() - # self.main_layout.addLayout(self.layout_btn) - # 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 - # ) - # self.save_btn.clicked.connect(self.on_save_clicked) - # self.layout_btn.addWidget(self.save_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 - # ) - # self.reset_btn.clicked.connect(self.reset_form) - # self.layout_btn.addWidget(self.reset_btn) - - -class MyFormPart(QWidget): - def __init__( - self, - field_definitons: Sequence[FormField], - group_name: str | None = None, - add_buttons: bool = False, - ): super().__init__() self.setStyleSheet(""" QGroupBox { @@ -487,23 +547,15 @@ class MyFormPart(QWidget): # --- LAYOUT --- self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) - self.form_layout = QFormLayout() - self.group_box: QGroupBox | None = None - self.group_name = group_name + self.form_field_groups = form_field_groups - if self.group_name: - self.group_box = QGroupBox(group_name) - self.main_layout.addWidget(self.group_box) - self.group_box.setLayout(self.form_layout) - else: - self.main_layout.addLayout(self.form_layout) - - self.form_layout.setSpacing(10) # Abstand zwischen den Zeilen - - self.field_definitions = field_definitons self.widgets: dict[str, QWidget] = {} - # automatic build - self.create_form_fields() + + for fg in form_field_groups: + form_group = FormGroupWidget(fg.fields, fg.label) + self.main_layout.addWidget(form_group) + self._add_widgets(form_group.widgets) + # self.widgets.update(form_group.widgets) # buttons self.add_buttons = add_buttons @@ -529,9 +581,197 @@ class MyFormPart(QWidget): self.reset_btn.clicked.connect(self.reset_form) self.layout_btn.addWidget(self.reset_btn) + def _add_widgets( + self, + widgets: dict[str, QWidget], + ) -> None: + current_keys = set(self.widgets.keys()) + new_keys = set(widgets.keys()) + shared_keys = current_keys.intersection(new_keys) + + if shared_keys: + raise ValueError(f"Tried to add fields with already assigned keys: {shared_keys}") + + self.widgets.update(widgets) + + def _disable_save(self) -> None: + self.save_btn.setEnabled(False) + self.save_btn.setText(self.save_btn_txt_disabled) + + 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.save_btn_txt_enabled) + + def on_save_clicked(self) -> None: + self._disable_save() + errors: list[str] = [] + + # time.sleep(0.5) + + # Wir gehen unsere Feld-Definitionen durch + for fg in self.form_field_groups: + for field in fg.fields: + widget = self.widgets[field.key] + + # 1. Zuerst setzen wir das Design des Feldes wieder auf "Normal" zurück. + # Falls der Nutzer den Fehler vorher schon korrigiert hat, muss der rote Rand weg! + if not field.readonly: + widget.setStyleSheet("") + + # 2. Ist es überhaupt ein Pflichtfeld? + if not field.required: + continue + + is_empty = False + if isinstance(widget, (QLineEdit, QDateEdit)): + if not widget.text().strip(): + is_empty = True + + elif isinstance(widget, QPlainTextEdit): + if not widget.toPlainText().strip(): + is_empty = True + + if not is_empty: + continue + + error = field.label.replace("*", "").replace(":", "") + if fg.label: + error = f"{fg.label}: {error}" + errors.append(error) + + # Optisches Feedback: Heller roter Hintergrund und roter Rand + widget.setStyleSheet(""" + border: 1px solid #ef4444; + background-color: #ffe9e9; + padding: 4px; + border-radius: 4px; + """) + + # --- ERGEBNIS AUSWERTEN --- + if errors: + # Es gibt Fehler! Speichern abbrechen und Pop-up anzeigen. + error_text = "Bitte fülle die folgenden Pflichtfelder aus:\n\n- " + "\n- ".join( + errors + ) + QMessageBox.warning(self, "Fehlende Angaben", error_text) + self._enable_save() + return + + # Wenn wir hier ankommen, ist die Liste 'errors' leer. Alles ist korrekt ausgefüllt! + # time.sleep(0.5) + print("Erfolg! Alle Daten sind valide.") + self.reset_form() + self._enable_save() + + def reset_form(self) -> None: + for fg in self.form_field_groups: + for field in fg.fields: + widget = self.widgets[field.key] + + if field.readonly: + continue + + if isinstance(widget, QLineEdit): + widget.clear() + if field.fill_value: + widget.setText(field.fill_value) + elif isinstance(widget, QPlainTextEdit): + widget.clear() + elif isinstance(widget, QDateEdit): + 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()) + elif isinstance(widget, QComboBox): + if field.fill_value: + widget.setCurrentText(field.fill_value) + + widget.setStyleSheet("") + + def get_form_data(self) -> ...: + raise NotImplementedError() + + data = {} + for key, widget in self.widgets.items(): + if isinstance(widget, (QLineEdit, QDateEdit)): + data[key] = widget.text() + elif isinstance(widget, QPlainTextEdit): + data[key] = widget.toPlainText() + + return data + + +class FormGroupWidget(QWidget): + def __init__( + self, + form_fields: Sequence[FormField], + group_name: str | None = None, + ): + super().__init__() + 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; + } + """) + + # --- LAYOUT --- + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.form_layout = QFormLayout() + self.group_box: QGroupBox | None = None + self.group_name = group_name + + if self.group_name: + self.group_box = QGroupBox(group_name) + self.main_layout.addWidget(self.group_box) + self.group_box.setLayout(self.form_layout) + else: + self.main_layout.addLayout(self.form_layout) + + self.form_layout.setSpacing(10) # Abstand zwischen den Zeilen + + self.form_fields = form_fields + self.widgets: dict[str, QWidget] = {} + # automatic build + self.create_form_fields() + def create_form_fields(self) -> None: - for field in self.field_definitions: - widget = None + for field in self.form_fields: + widget: QWidget | None = None match field.type: case FormFieldType.TEXT: @@ -583,120 +823,40 @@ class MyFormPart(QWidget): if field.fill_value: widget.setText(field.fill_value) + case FormFieldType.DROPDOWN: + widget = QComboBox() + assert field.options + widget.addItems(field.options) + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.fill_value: + widget.setCurrentText(field.fill_value) + if field.readonly: + widget.setEnabled(False) + widget.setProperty("styleClass", "stempel") + case _: raise NotImplementedError(f"Not supported field type: {field.type.value}") - if widget: - self.widgets[field.key] = widget + self.widgets[field.key] = widget + + if not field.tooltip: self.form_layout.addRow(field.label, widget) - - def get_form_data(self) -> ...: - """Liest alle Felder automatisch aus""" - data = {} - for key, widget in self.widgets.items(): - if isinstance(widget, (QLineEdit, QDateEdit)): - data[key] = widget.text() - elif isinstance(widget, QPlainTextEdit): - data[key] = widget.toPlainText() - - return data - - def _disable_save(self) -> None: - self.save_btn.setEnabled(False) - self.save_btn.setText(self.save_btn_txt_disabled) - - 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.save_btn_txt_enabled) - - def on_save_clicked(self) -> None: - self._disable_save() - errors = [] # Hier sammeln wir die Namen der fehlenden Felder - - # time.sleep(0.5) - - # Wir gehen unsere Feld-Definitionen durch - for field in self.field_definitions: - widget = self.widgets[field.key] - - # 1. Zuerst setzen wir das Design des Feldes wieder auf "Normal" zurück. - # Falls der Nutzer den Fehler vorher schon korrigiert hat, muss der rote Rand weg! - if not field.readonly: - widget.setStyleSheet("") - - # 2. Ist es überhaupt ein Pflichtfeld? - if not field.required: continue - is_empty = False - if isinstance(widget, (QLineEdit, QDateEdit)): - if not widget.text().strip(): - is_empty = True + field_layout = QHBoxLayout() + field_layout.setContentsMargins(0, 0, 0, 0) + field_layout.setSpacing(5) + info_btn = QPushButton("ℹ️") + # info_btn = QPushButton("?") + info_btn.setFixedSize(28, 27) + info_btn.setFlat(True) + info_btn.setCursor(Qt.CursorShape.PointingHandCursor) + info_btn.setToolTip(field.tooltip) + field_layout.addWidget(widget) + field_layout.addWidget(info_btn) - elif isinstance(widget, QPlainTextEdit): - if not widget.toPlainText().strip(): - is_empty = True - - if not is_empty: - continue - - errors.append( - field.label.replace("*", "").replace(":", "") - ) # Sternchen für die Fehlermeldung entfernen - - # Optisches Feedback: Heller roter Hintergrund und roter Rand - widget.setStyleSheet(""" - border: 1px solid #ef4444; - background-color: #ffe9e9; - padding: 4px; - border-radius: 4px; - """) - - # --- ERGEBNIS AUSWERTEN --- - if errors: - # Es gibt Fehler! Speichern abbrechen und Pop-up anzeigen. - error_text = "Bitte fülle die folgenden Pflichtfelder aus:\n\n- " + "\n- ".join( - errors - ) - QMessageBox.warning(self, "Fehlende Angaben", error_text) - self._enable_save() - return - - # Wenn wir hier ankommen, ist die Liste 'errors' leer. Alles ist korrekt ausgefüllt! - # time.sleep(0.5) - print("Erfolg! Alle Daten sind valide.") - self.reset_form() - self._enable_save() - - def reset_form(self) -> None: - for field in self.field_definitions: - widget = self.widgets[field.key] - - if field.readonly: - continue - - if isinstance(widget, QLineEdit): - widget.clear() - if field.fill_value: - widget.setText(field.fill_value) - elif isinstance(widget, QPlainTextEdit): - widget.clear() - elif isinstance(widget, QDateEdit): - 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()) - - widget.setStyleSheet("") + self.form_layout.addRow(field.label, field_layout) class ClickableCell(QFrame): @@ -999,7 +1159,7 @@ class SearchFormPage(QWidget): title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold; container_layout.addWidget(title) # container_layout.addWidget(MyFormPart(FORM_FIELD_DEF, "Test-Gruppe")) - container_layout.addWidget(MyForm(FORM_FIELD_GROUPS)) + container_layout.addWidget(AutoForm(FORM_FIELD_GROUPS)) container_layout.addSpacing(30)