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 enum
import sys import sys
import time import time
import uuid
from collections.abc import Sequence from collections.abc import Sequence
from PySide6.QtCore import QDate, Qt, QTimer, Signal # Signal ist wichtig! 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) form_layout.addRow("Suche:", self.search_input)
# search_data = [addr.name for addr in ADDRESSES] # search_data = [addr.name for addr in ADDRESSES]
# self.SEARCH_MAP = {addr.name: addr 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_MAP = be_init_rec.comp_search_choice_mapping()
self.search_data = tuple(self.SEARCH_MAP.keys()) self.search_data = tuple(self.SEARCH_MAP.keys())
self.completer = QCompleter(self.search_data) self.completer = QCompleter(self.search_data)
@ -304,48 +307,82 @@ class FormFieldType(enum.StrEnum):
LONGTEXT = enum.auto() LONGTEXT = enum.auto()
DATE = enum.auto() DATE = enum.auto()
DATETIME = enum.auto() DATETIME = enum.auto()
DROPDOWN = enum.auto()
@dc.dataclass(slots=True) @dc.dataclass(slots=True)
class FormField: class FormField:
key: str
label: str label: str
type: FormFieldType type: FormFieldType
required: bool required: bool
placeholder: str | None = None placeholder: str | None = None
fill_value: str | None = None fill_value: str | None = None
readonly: bool = False readonly: bool = False
options: Sequence[str] | None = None
key: str = ""
tooltip: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.key:
self.key = str(uuid.uuid4())
self.label = self.label.strip() self.label = self.label.strip()
if not self.label.endswith(":"): if not self.label.endswith(":"):
self.label += ":" self.label += ":"
if self.required: if self.required:
self.label += "*" 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) @dc.dataclass(slots=True)
class FormFieldGroup: class FormFieldGroup:
key: str label: str | None
label: str
fields: Sequence[FormField] fields: Sequence[FormField]
FORM_FIELD_DEF = [ FORM_FIELD_DEF = [
FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."),
FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False),
FormField( 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", "Externe Daten",
FormFieldType.TEXT, FormFieldType.TEXT,
True, True,
fill_value="Lorem ipsum und so weiter...", fill_value="Lorem ipsum und so weiter...",
readonly=True, readonly=True,
), ),
FormField("init_date", "Auftragsdatum", FormFieldType.DATE, True), FormField("Auftragsdatum", FormFieldType.DATE, True),
FormField("start_date", "Startdatum", FormFieldType.DATE, True), FormField("Startdatum", FormFieldType.DATE, True),
FormField( FormField(
"ms_date1",
"MS Datum 1", "MS Datum 1",
FormFieldType.DATE, FormFieldType.DATE,
False, False,
@ -354,7 +391,6 @@ FORM_FIELD_DEF = [
readonly=True, readonly=True,
), ),
FormField( FormField(
"ms_date2",
"MS Datum 2", "MS Datum 2",
FormFieldType.DATE, FormFieldType.DATE,
False, False,
@ -363,7 +399,6 @@ FORM_FIELD_DEF = [
readonly=False, readonly=False,
), ),
FormField( FormField(
"important:notes",
"Wichtige Notizen", "Wichtige Notizen",
FormFieldType.LONGTEXT, FormFieldType.LONGTEXT,
True, True,
@ -372,20 +407,18 @@ FORM_FIELD_DEF = [
] ]
FORM_FIELD_DEF2 = [ FORM_FIELD_DEF2 = [
FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), FormField("Projektname", FormFieldType.TEXT, True, "Bitte füllen..."),
FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False), FormField("Beschreibung", FormFieldType.LONGTEXT, False),
FormField( FormField(
"ext_data",
"Externe Daten", "Externe Daten",
FormFieldType.TEXT, FormFieldType.TEXT,
True, True,
fill_value="Lorem ipsum und so weiter...", fill_value="Lorem ipsum und so weiter...",
readonly=True, readonly=True,
), ),
FormField("init_date", "Auftragsdatum", FormFieldType.DATE, True), FormField("Auftragsdatum", FormFieldType.DATE, True),
FormField("start_date", "Startdatum", FormFieldType.DATE, True), FormField("Startdatum", FormFieldType.DATE, True),
FormField( FormField(
"ms_date1",
"MS Datum 1", "MS Datum 1",
FormFieldType.DATE, FormFieldType.DATE,
False, False,
@ -394,7 +427,6 @@ FORM_FIELD_DEF2 = [
readonly=True, readonly=True,
), ),
FormField( FormField(
"ms_date2",
"MS Datum 2", "MS Datum 2",
FormFieldType.DATE, FormFieldType.DATE,
False, False,
@ -403,7 +435,6 @@ FORM_FIELD_DEF2 = [
readonly=False, readonly=False,
), ),
FormField( FormField(
"important:notes",
"Wichtige Notizen", "Wichtige Notizen",
FormFieldType.LONGTEXT, FormFieldType.LONGTEXT,
True, 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 = [ FORM_FIELD_GROUPS = [
FormFieldGroup("group1", "Test-1", FORM_FIELD_DEF), FormFieldGroup(
FormFieldGroup("group2", "Test-2", FORM_FIELD_DEF2), "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__( def __init__(
self, self,
form_field_groups: Sequence[FormFieldGroup], form_field_groups: Sequence[FormFieldGroup],
add_buttons: bool = True, add_buttons: bool = True,
) -> None: ) -> 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__() super().__init__()
self.setStyleSheet(""" self.setStyleSheet("""
QGroupBox { QGroupBox {
@ -487,23 +547,15 @@ class MyFormPart(QWidget):
# --- LAYOUT --- # --- LAYOUT ---
self.main_layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setContentsMargins(0, 0, 0, 0)
self.form_layout = QFormLayout() self.form_field_groups = form_field_groups
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.field_definitions = field_definitons
self.widgets: dict[str, QWidget] = {} 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 # buttons
self.add_buttons = add_buttons self.add_buttons = add_buttons
@ -529,9 +581,197 @@ class MyFormPart(QWidget):
self.reset_btn.clicked.connect(self.reset_form) self.reset_btn.clicked.connect(self.reset_form)
self.layout_btn.addWidget(self.reset_btn) 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: def create_form_fields(self) -> None:
for field in self.field_definitions: for field in self.form_fields:
widget = None widget: QWidget | None = None
match field.type: match field.type:
case FormFieldType.TEXT: case FormFieldType.TEXT:
@ -583,120 +823,40 @@ class MyFormPart(QWidget):
if field.fill_value: if field.fill_value:
widget.setText(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 _: case _:
raise NotImplementedError(f"Not supported field type: {field.type.value}") 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) 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 continue
is_empty = False field_layout = QHBoxLayout()
if isinstance(widget, (QLineEdit, QDateEdit)): field_layout.setContentsMargins(0, 0, 0, 0)
if not widget.text().strip(): field_layout.setSpacing(5)
is_empty = True 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): self.form_layout.addRow(field.label, field_layout)
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("")
class ClickableCell(QFrame): class ClickableCell(QFrame):
@ -999,7 +1159,7 @@ class SearchFormPage(QWidget):
title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold; title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold;
container_layout.addWidget(title) container_layout.addWidget(title)
# container_layout.addWidget(MyFormPart(FORM_FIELD_DEF, "Test-Gruppe")) # 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) container_layout.addSpacing(30)