dataclass-driven form generation

This commit is contained in:
Florian Förster 2026-04-30 14:51:35 +02:00
parent c5aadd502d
commit 3dbc9ecfcb

View File

@ -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)