from __future__ import annotations import copy import dataclasses as dc import datetime import enum import json import pickle import re import sys import uuid from collections import defaultdict from collections.abc import Container, Iterable, Sequence from pathlib import Path from pprint import pformat from typing import Annotated, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast from typing_extensions import override import babel from pydantic import ( AwareDatetime, BaseModel, ConfigDict, EmailStr, Field, ValidationError, field_validator, model_validator, ) from PySide6.QtCore import ( QDate, QEvent, QObject, Qt, QTimer, Signal, ) from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, QComboBox, QCompleter, QDateEdit, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLayout, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, ) import wce_crm.constants from wce_crm.backend import backend as be_init_rec from wce_crm.logging import ( logger_auto_form, logger_get_data, logger_gui, ) K = TypeVar("K") V = TypeVar("V") DEBUG: bool = True DEBUG_SEARCH_WIDGET: bool = False DEBUG_NO_DATABASE: bool = False DEBUG_GET_SET: bool = False if not wce_crm.constants.Config.DEVELOPMENT_STATE: DEBUG = False DEBUG_SEARCH_WIDGET = False DEBUG_NO_DATABASE = False DEBUG_GET_SET = False # TODO remove # logger_gui = BASE_LOGGER.getChild("wce") # logger_gui.setLevel(logging.DEBUG) # logger_search_widget = logger_gui.getChild("search_widget") # logger_search_widget.setLevel(logging.DEBUG) # logger_get_data = logger_gui.getChild("get_data") # logger_get_data.setLevel(logging.DEBUG) # logger_auto_form = logger_gui.getChild("get_data_auto_form") # logger_auto_form.setLevel(logging.DEBUG) QSS = """ *[styleClass="stempel"] { background-color: #f1f5f9; color: #333D4B; border: 1px dashed #cbd5e1; border-radius: 4px; padding: 5px; } *[styleClass="stempel"]:focus { border: 1px dashed #cbd5e1; } """ DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]") COLUMN_SEP: Final[str] = "__" DATETIME_FMT: Final[str] = "%d.%m.%Y %H:%M:%S" DATE_FMT: Final[str] = "%d.%m.%Y" def save_pydantic_model_dict_db( model: FlatBaseModel, 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") export = model.to_db() with open(path, "wb") as f: pickle.dump(export, f) def load_pydantic_model_dict_db( 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") if not path.exists(): raise FileNotFoundError(f"Provided path for model dict not found: >{path}<") with open(path, "rb") as f: model_dict = pickle.load(f) return model_dict def merge_dicts_to_lists( dict_iter: Iterable[dict[K, V]], ) -> dict[K, list[V]]: merged = defaultdict(list) for d in dict_iter: for key, value in d.items(): merged[key].append(value) return dict(merged) def unmerge_dict_to_list( merged_dict: dict[K, list[V]], ) -> list[dict[K, V]]: if not merged_dict: return [] keys = merged_dict.keys() value_lists = merged_dict.values() return [dict(zip(keys, row)) for row in zip(*value_lists)] class FlatBaseModel(BaseModel): """ Optimised Pydantic base class, which parses JSON strings and column separators recursively and correctly """ @classmethod def _recursive_parse_json(cls, data: Any) -> Any: """look for JSON list strings and parse them""" if isinstance(data, str) and data.startswith("[") and data.endswith("]"): try: parsed = json.loads(data) # Falls die Liste selbst wieder konvertiert werden muss (z.B. Sub-Dicts) return cls._recursive_parse_json(parsed) except json.JSONDecodeError: return data elif isinstance(data, dict): return {k: cls._recursive_parse_json(v) for k, v in data.items()} elif isinstance(data, list): return [cls._recursive_parse_json(item) for item in data] return data @classmethod def _recursive_unflatten(cls, data: Any) -> Any: """building nested structure using column spearator sequence""" if isinstance(data, dict): unflattened_level = {} for key, value in data.items(): if COLUMN_SEP in key: parts = key.split(COLUMN_SEP) aktuell = unflattened_level for part in parts[:-1]: if part not in aktuell or not isinstance(aktuell[part], dict): aktuell[part] = {} aktuell = aktuell[part] aktuell[parts[-1]] = value else: unflattened_level[key] = value return {k: cls._recursive_unflatten(v) for k, v in unflattened_level.items()} elif isinstance(data, list): return [cls._recursive_unflatten(item) for item in data] return data @model_validator(mode="before") @classmethod def __unflatten_input(cls, data: Any) -> Any: # type: ignore """entry control: prepare flat DB/GUI data for Pydantic""" if not isinstance(data, dict): return data # setp 1: convert all JSON-Strings to lists json_parsed_data = cls._recursive_parse_json(data) # step 2: build nested structure based on defined separator sequence final_nested_data = cls._recursive_unflatten(json_parsed_data) return final_nested_data def to_db(self, *args, **kwargs) -> dict[str, Any]: """output for DB: flat, lists as JSON-Strings""" nested = super().model_dump(*args, **kwargs) return self.__flatten_dict(nested, serialize_lists=True) def to_gui(self, *args, **kwargs) -> dict[str, Any]: """output for GUI: flat, but lists remain Python lists""" nested = super().model_dump(*args, **kwargs) return self.__flatten_dict(nested, serialize_lists=False) @classmethod def __flatten_dict( cls, nested_dict: dict, parent_key: str = "", serialize_lists: bool = True, ) -> dict[str, Any]: """recursive function to flatten the structure (for outputs)""" items = [] for k, v in nested_dict.items(): new_key = f"{parent_key}{COLUMN_SEP}{k}" if parent_key else k if isinstance(v, dict): items.extend(cls.__flatten_dict(v, new_key, serialize_lists).items()) elif isinstance(v, list): processed_list = [] for item in v: if isinstance(item, dict): processed_list.append( cls.__flatten_dict(item, serialize_lists=serialize_lists) ) else: processed_list.append(item) if serialize_lists: items.append((new_key, json.dumps(processed_list, default=_parse_json))) else: items.append((new_key, processed_list)) else: items.append((new_key, v)) return dict(items) def _parse_json(value: Any) -> str: if isinstance(value, datetime.date): return value.isoformat() elif isinstance(value, datetime.datetime): return value.isoformat() else: raise TypeError @dc.dataclass(slots=True) class CountryList: iso_to_country: dict[str, str] for_dropdown: Sequence[tuple[str, str]] def get_country_list_german() -> CountryList: locale = babel.Locale("de", "DE") countries: list[tuple[str, str]] = [] iso_to_country: dict[str, str] = {} for iso_code, country_name in locale.territories.items(): if len(iso_code) == 2 and not iso_code.isdigit(): countries.append((country_name, iso_code)) iso_to_country[iso_code] = country_name countries.sort(key=lambda x: x[0]) return CountryList( iso_to_country=iso_to_country, for_dropdown=tuple(countries), ) def get_list_germany_states() -> CountryList: states: list[tuple[str, str]] = [] short_code_to_name: dict[str, str] = {} STATE_LIST: list[tuple[str, str]] = [ ("Bayern", "BY"), ("Niedersachen", "NI"), ("Baden-Württemberg", "BW"), ("Berlin", "BE"), ("Brandenburg", "BB"), ("Bremen", "HB"), ("Hamburg", "HH"), ("Hessen", "HE"), ("Mecklenburg", "MV"), ("Nordrhein-Westfalen", "NW"), ("Rheinland-Pfalz", "RP"), ("Saarland", "SL"), ("Sachsen", "SN"), ("Sachsen-Anhalt", "ST"), ("Schleswig-Holstein", "SH"), ("Thüringen", "TH"), ] STATE_LIST.sort(key=lambda x: x[1]) 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, for_dropdown=tuple(states), ) COUNTRY_LIST: Final[CountryList] = get_country_list_german() GERMAN_STATE_LIST: Final[CountryList] = 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: print("---\n\n>>> Widget registry:") for key, entry in widget_registry.items(): print(f"Key: {key}") print(f"\twidget: {entry['widget']}") print(f"\tfield key: {entry['form_field'].key}") print(f"\tfield type: {entry['form_field'].type}") class FormFieldType(enum.StrEnum): GROUP = enum.auto() TEXT = enum.auto() LONGTEXT = enum.auto() DATE = enum.auto() DATETIME = enum.auto() DROPDOWN = enum.auto() EXTENDED_DROPDOWN = enum.auto() DYNAMIC_LIST = enum.auto() DYNAMIC_DROPDOWN_NUMERIC = enum.auto() DYNAMIC_DROPDOWN_OPTION = enum.auto() TEXT_SEARCH = enum.auto() CUSTOM = enum.auto() TEXT_DATE = enum.auto() TEXT_DATETIME = enum.auto() @dc.dataclass(slots=True) class DropdownOption: label: str _data: dc.InitVar[Any | None] = None data: Any = dc.field(init=False) def __post_init__( self, _data: Any | None, ) -> None: if _data is None: self.data = self.label else: self.data = _data @dc.dataclass(slots=True) class FormField: label: str type: FormFieldType children: Sequence[FormField] = dc.field(default_factory=list) parent: FormField | None = None required: bool = False placeholder: str = "" fill_value: str = "" readonly: bool = False options: dc.InitVar[Sequence[tuple[str, Any]]] = tuple() dropdown_options: Sequence[DropdownOption] = dc.field(default=tuple(), init=False) key: str = "" tooltip: str = "" info: str = "" custom_widget: str = "" init_label: str = dc.field(init=False) ignore_get_data: bool = False trigger_value: str = "" enable_uuid_key: bool = False def __post_init__( self, options: Sequence[tuple[str, Any]], ) -> None: if not self.key and self.enable_uuid_key: self.key = str(uuid.uuid4()) self.label = self.label.strip() self.init_label = self.label.replace("*", "").replace(":", "") if not self.label.endswith(":") and self.type is not FormFieldType.GROUP: self.label += ":" if self.required: self.label += "*" if self.type is FormFieldType.CUSTOM and not self.custom_widget: raise ValueError("Custom widget must be named using parameter >custom_widget<") elif self.type is FormFieldType.CUSTOM and self.custom_widget not in CUSTOM_WIDGETS: raise KeyError( ( f"Custom widget >{self.custom_widget}< is not a known member " "of the custom widget registry" ) ) if self.type in (FormFieldType.DROPDOWN, FormFieldType.EXTENDED_DROPDOWN): self.dropdown_options = tuple(DropdownOption(op[0], op[1]) for op in options) if self.type is FormFieldType.DYNAMIC_DROPDOWN_OPTION and not self.trigger_value: raise ValueError( "Dynamic Dropdown Option Widget must have a defined option or decision value" ) if self.children: self.required = self.required or any((child.required for child in self.children)) for child in self.children: child.parent = self def enhanced_label( self, add_text: str, ) -> str: enhanced_label = self.init_label + f" {add_text}" if not enhanced_label.endswith(":") and self.type is not FormFieldType.GROUP: enhanced_label += ":" if self.required: enhanced_label += "*" return enhanced_label class CustomForm(Protocol): def get_form_data(self) -> dict[str, Any]: ... def set_form_data(self, data: Any) -> None: ... def reset_form(self) -> None: ... def validate_form_data(self) -> list[str]: ... class AutoFormInsert(Protocol): def __call__( self, data: dict[str, Any], ) -> None: ... class AutoFormUpdate(Protocol): def __call__( self, id_: int, data: dict[str, Any], ) -> None: ... class AutoFormGet(Protocol): def __call__( self, id_: int, ) -> dict[str, Any]: ... @dc.dataclass(slots=True) class AutoFormConfig: model: type[FlatBaseModel] data_insert: AutoFormInsert data_update: AutoFormUpdate data_get: AutoFormGet form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = True class CustomWidget(QWidget): def __init__( self, form_fields: Sequence[FormField], label: str = "Suche", prefix: str = "", ) -> None: super().__init__() self.form_fields = form_fields self.label = label self.prefix = prefix def get_form_data(self) -> dict[str, Any]: ... def set_form_data(self, data: Any) -> None: ... def reset_form(self) -> None: ... def validate_form_data(self) -> list[str]: ... def _build_ui_recursively( schema: Sequence[FormField], parent_layout: QFormLayout, widget_registry: WidgetRegistry, prefix: str = "", ) -> list[str]: no_scroll_filter = NoScrollFilter(parent_layout) keys: list[str] = [] for field in schema: if prefix and field.key: full_key = f"{prefix}{COLUMN_SEP}{field.key}" if prefix else field.key elif prefix and not field.key: full_key = f"{prefix}" else: full_key = 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 | FormFieldType.TEXT_DATE | FormFieldType.TEXT_DATETIME: 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, } widget.installEventFilter(no_scroll_filter) 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.TEXT_SEARCH: widget = QLineEdit() if field.placeholder: widget.setPlaceholderText(field.placeholder) else: widget.setPlaceholderText("Tippen zum Suchen...") search_completer = QCompleter() search_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) search_completer.setFilterMode(Qt.MatchFlag.MatchContains) widget.setCompleter(search_completer) widget_registry[full_key] = { "widget": widget, "form_field": field, } widget.installEventFilter(no_scroll_filter) 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, } widget.installEventFilter(no_scroll_filter) 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, } widget.installEventFilter(no_scroll_filter) 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() 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, } widget.installEventFilter(no_scroll_filter) 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) 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, } widget.installEventFilter(no_scroll_filter) 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_NUMERIC: widget = DynamicDropdownWidgetNumeric( 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_OPTION: widget = DynamicDropdownWidgetOption( field.children, field.trigger_value, field.label, prefix=f"{full_key}", ) widget_registry[full_key] = { "widget": widget, "form_field": field, } parent_layout.addRow(widget) case FormFieldType.CUSTOM: widget_class = CUSTOM_WIDGETS[field.custom_widget] widget = widget_class( form_fields=field.children, label=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}") keys.append(full_key) return keys def _add_tooltip( widget: QWidget, tooltip: str, ) -> QHBoxLayout: field_layout = QHBoxLayout() field_layout.setContentsMargins(0, 0, 0, 0) field_layout.setSpacing(5) info_btn = QPushButton("ℹ️") info_btn.setFixedSize(28, 27) info_btn.setFlat(True) info_btn.setCursor(Qt.CursorShape.PointingHandCursor) info_btn.setToolTip(tooltip) field_layout.addWidget(widget) field_layout.addWidget(info_btn) return field_layout def reset_form( widget_registry: WidgetRegistry, ) -> None: for registry_entry in widget_registry.values(): # type: ignore widget = registry_entry["widget"] form_field = registry_entry["form_field"] # TODO check if this behaviour is correct in other contexts, deactivated because of # TODO company search widget # if form_field.readonly: # continue if isinstance(widget, QLineEdit): widget.clear() if form_field.fill_value: widget.setText(form_field.fill_value) elif isinstance(widget, QPlainTextEdit): widget.clear() elif isinstance(widget, QDateEdit): if form_field.fill_value: set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy") if not set_date.isValid(): raise ValueError( f"Could not parse date field value >{form_field.fill_value}<" ) widget.setDate(set_date) else: widget.setDate(QDate.currentDate()) elif isinstance(widget, QComboBox): if form_field.fill_value: widget.setCurrentText(form_field.fill_value) elif form_field.type is FormFieldType.DROPDOWN: widget.setCurrentIndex(0) else: widget.setCurrentIndex(-1) elif isinstance( widget, ( DynamicListWidget, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, CustomWidget, ), ): # custom widget classes manage their widgets on their own widget.reset_form() widget.setStyleSheet("") # TODO: check removal # 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 @dc.dataclass(slots=True) class SubForm: entry_box: QWidget prefix_parent: str index: int prefix: str = "" registry: WidgetRegistry = dc.field(default_factory=dict) full_keys: list[str] = dc.field(default_factory=list) def __post_init__(self) -> None: self.prefix = f"{self.prefix_parent}-[{self.index}]" def update_index(self, new_idx: int) -> None: self.index = new_idx self.prefix = f"{self.prefix_parent}-[{self.index}]" def change_sub_form_widget_registry( widget_registry: WidgetRegistry, sub_form: SubForm, new_idx: int, ) -> None: if sub_form.index == new_idx: return old_key_part = sub_form.prefix new_key_part = f"{sub_form.prefix_parent}-[{new_idx}]" for key in tuple(widget_registry.keys()): splitted = key.split(COLUMN_SEP) key_part, rest = splitted[0], splitted[1:] if key_part == old_key_part: old_key = COLUMN_SEP.join([old_key_part] + rest) new_key = COLUMN_SEP.join([new_key_part] + rest) widget_registry[new_key] = widget_registry[old_key] del widget_registry[key] sub_form.update_index(new_idx) def update_sub_forms( widget_registry: WidgetRegistry, sub_forms: Sequence[SubForm], base_label: str = "", ): total_num_sub_forms = len(sub_forms) for index, sub_form in enumerate(sub_forms, start=1): if isinstance(sub_form.entry_box, QGroupBox) and base_label: sub_form.entry_box.setTitle(f"{base_label} {index}") change_sub_form_widget_registry( widget_registry, sub_form, index, ) for key in tuple(widget_registry.keys()): matches = DYNAMIC_LIST_KEY_PATTERN.search(key) if not matches: continue counter_sub_form = int(matches.group(1)) if counter_sub_form > total_num_sub_forms: del widget_registry[key] def get_form_data( widget_registry: WidgetRegistry, filter_keys: Container[str] = tuple(), ) -> dict[str, Any]: raw_data: dict[str, Any] = {} ignored_keys: list[str] = [] for key, registry_entry in widget_registry.items(): value: Any | None = None if filter_keys: data_key = key.split(COLUMN_SEP)[-1] if data_key not in filter_keys: continue widget = registry_entry["widget"] form_field = registry_entry["form_field"] if form_field.ignore_get_data: ignored_keys.append(key) continue if isinstance(widget, QLineEdit): data = widget.text() if data != "": value = data if form_field.type is FormFieldType.TEXT_DATE: value = datetime.datetime.strptime(value, DATE_FMT).date() elif form_field.type is FormFieldType.TEXT_DATETIME: value = datetime.datetime.strptime(value, DATETIME_FMT) elif isinstance(widget, QPlainTextEdit): data = widget.toPlainText() if data != "": value = data elif isinstance(widget, QDateEdit): qt_date = widget.date() value = qt_date.toPython() elif isinstance(widget, QComboBox): value = widget.currentData() elif isinstance(widget, DynamicListWidget): # TODO add to other custom widgets # this should be a list: each dynamic list contains a list # of such dictionaries value = widget.get_form_data() elif isinstance( widget, (DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, CustomWidget) ): # this is a special data structure with some assumptions of the widget's internals value = widget.get_form_data() # TODO: check removal # logger_get_data.info("Key: %s", key) # logger_get_data.info("Value: %s", value) if isinstance(value, dict): raw_data.update(value) else: raw_data[key] = value logger_get_data.debug("[base:get_form_data]>>>>> RAW DATA:\n%s", pformat(raw_data)) return raw_data def set_widget_value( widget: QWidget, value: Any, ) -> None: if value is None: return elif value is True: value = "ja" elif value is False: value = "nein" if isinstance(widget, QLineEdit): if isinstance(value, datetime.datetime): value = value.astimezone() value = value.strftime(DATETIME_FMT) elif isinstance(value, datetime.date): value = value.strftime(DATE_FMT) widget.setText(str(value)) elif isinstance(widget, QPlainTextEdit): widget.setPlainText(str(value)) elif isinstance(widget, QDateEdit): assert isinstance(value, datetime.date) set_date = QDate(value.year, value.month, value.day) if not set_date.isValid(): raise ValueError(f"Could not parse date field value >{value}<") widget.setDate(set_date) elif isinstance(widget, QComboBox): index = widget.findData(value) if index >= 0: widget.setCurrentIndex(index) elif isinstance(widget, DynamicListWidget): assert isinstance(value, list) widget.set_form_data(value) elif isinstance( widget, ( DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, Grunderfassung_SuchWidget, ), ): assert isinstance(value, dict) widget.set_form_data(value) def set_form_data( widget_registry: WidgetRegistry, data: dict[str, Any], filter_keys: Container[str] = tuple(), ) -> None: for key, entry in widget_registry.items(): widget = entry["widget"] if filter_keys: data_key = key.split(COLUMN_SEP)[-1] if data_key not in filter_keys: continue # TODO check removal # key_path = key.split(COLUMN_SEP) # value = _get_nested(data, key_path) # value = data.get(key, None) if isinstance( widget, ( DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, Grunderfassung_SuchWidget, ), ): value = data else: if key not in data: logger_gui.debug("---- Key not in data: %s") logger_gui.debug("-------- Data: %s", pformat(data)) value = data[key] logger_gui.debug("---- Key set: %s", key) set_widget_value(widget, value) def validate_form_data( widget_registry: WidgetRegistry, ) -> list[str]: errors: list[str] = [] for key, registry_entry in widget_registry.items(): error_post: bool = False 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, DynamicDropdownWidgetNumeric, DynamicDropdownWidgetOption, CustomWidget, ), ): 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(FlatBaseModel): # default in SQLAlchemy with lambda and timezone-aware datetime) Metadaten_erstellung: AwareDatetime | None = None Metadaten_aktualisierung: AwareDatetime | None = None # see above Metadaten_nutzer: str | None Grunderfassung_fallnummer: str Grunderfassung_notiz: str | None Partnersuche: Grunderfassung_PartnerSuche Projektrelevanz: Grunderfassung_Projektrelevanz Kontaktperson: Grunderfassung_Kontaktperson Stammdaten: Grunderfassung_Stammdaten WeitereInfos: Grunderfassung_WeitereInfos Schulbildung: list[Grunderfassung_Schulbildung] HoehereBildung: list[Grunderfassung_HoehereBildung] Arbeitserfahrung: list[Grunderfassung_Arbeitserfahrung] Sprachkenntnisse: list[Grunderfassung_Sprachen] class Grunderfassung_PartnerSuche(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) un_suche: int person_suche: int kanal_aufmerksamkeit: str | None class Grunderfassung_Projektrelevanz(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) relevanz: str foerderperiode: str | None = None # @field_validator("relevanz", mode="before") # @classmethod # def str_to_bool(cls, value: Any) -> Any: # if isinstance(value, str): # value = value.strip().lower() # if value == "ja": # return True # elif value == "nein": # return False # raise ValueError("Wert muss 'ja', 'nein', True oder False sein.") # return value # TODO remove # class Grunderfassung_ProjektrelevanzStatus_Status(BaseModel): # model_config = ConfigDict(str_strip_whitespace=True) # relevanz: bool # foerderperiode: list[str | None] | None = None # @field_validator("relevanz", mode="before") # @classmethod # def str_to_bool(cls, value: Any) -> Any: # if isinstance(value, str): # value = value.strip().lower() # if value == "ja": # return True # elif value == "nein": # return False # raise ValueError("Wert muss 'ja', 'nein', True oder False sein.") # return value class Grunderfassung_Kontaktperson(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) KP_name_partner: str | None KP_titel: str | None KP_anrede_anschrift: str | None KP_name: str | None KP_vorname: str | None KP_festnetznummer: str | None KP_mobilfunknummer: str | None KP_email: EmailStr | None KP_funktion_beziehung: str | None KP_adresse: str | None ValidAge = Annotated[int, Field(ge=0, le=99)] class Grunderfassung_Stammdaten(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) titel: str | None anrede_anschrift: str name: str vorname: str | None geburtsdatum: datetime.date | None herkunftsland: str staatsangehoerigkeit: str | None rueckkehrer: bool | None aufenthaltsort: str | None strasse: str | None hausnummer: str | None PLZ: str | None ort: str | None bundesland: str | None land: str | None festnetznummer: str | None mobilfunknummer: str | None email: EmailStr | None familienstand: str | None anzahl_kinder: Grunderfassung_Stammdaten_AnzahlKinder @field_validator("rueckkehrer", mode="before") @classmethod def str_to_bool(cls, value: Any) -> Any: if isinstance(value, str): value = value.strip().lower() if value == "ja": return True elif value == "nein": return False raise ValueError("Wert muss 'ja', 'nein', True oder False sein.") return value class Grunderfassung_Stammdaten_AnzahlKinder(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) anzahl: int | None alter: list[ValidAge | None] | None = None class Grunderfassung_WeitereInfos(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) WI_deutsch_sprache: str | None WI_aufenthaltstitel: str | None WI_gueltigkeit_aufenthaltstitel: datetime.date | None WI_arbeitsstatus: str | None WI_meldung_institution: str | 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_HoehereBildung(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) HB_anerkennung: str | None HB_abschlussgrad: str | None HB_abschlussgrad_dokument: str | None HB_organisation: str | None HB_beruf: str | None HB_land: str | None HB_ort: str | None HB_abschlussjahr: str | None HB_bemerkungsfeld: str | None class Grunderfassung_Arbeitserfahrung(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) AE_branche: str | None AE_bezeichnung: str | None AE_funktion: str | None AE_unternehmen: str | None AE_land: str | None AE_zeitspanne: str | None AE_beschaeftigungsart: str | None AE_bemerkungsfeld: str | None class Grunderfassung_Sprachen(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) SP_sprache: str | None SP_niveau: str | None SP_nachweis: str | None SP_art_nachweis: str | None = None SP_datum_nachweis: datetime.date | None = None class Grunderfassung_SuchWidget(CustomWidget): def __init__( self, form_fields: Sequence[FormField], label: str = "Suche", prefix: str = "", ): super().__init__( form_fields=form_fields, label=label, prefix=prefix, ) self.form_fields = form_fields self.label = label self.prefix = prefix self.widget_registry: WidgetRegistry = {} self.export_data: dict[str, Any] = {} self.PROPERTY_MA_ID = "user_ma_id" main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) self.form_layout = QFormLayout() self.form_layout.setSpacing(10) main_layout.addLayout(self.form_layout) _build_ui_recursively( self.form_fields, self.form_layout, self.widget_registry, self.prefix, ) self.COMPANY_SEARCH_INPUT_KEY = "un_suche" lookup = search_widgets_by_key(self.widget_registry, self.COMPANY_SEARCH_INPUT_KEY) assert len(lookup) == 1 self.company_search_input = cast(QComboBox, lookup[0]["widget"]) self.company_search_input.currentIndexChanged.connect(self._selected_company) self.PERSON_SEARCH_INPUT_KEY = "person_suche" lookup = search_widgets_by_key(self.widget_registry, self.PERSON_SEARCH_INPUT_KEY) assert len(lookup) == 1 self.person_search_input = cast(QComboBox, lookup[0]["widget"]) self.person_search_input.currentIndexChanged.connect(self._selected_person) self.CHANNEL_AWARENESS_KEY = "kanal_aufmerksamkeit" lookup = search_widgets_by_key(self.widget_registry, self.CHANNEL_AWARENESS_KEY) assert len(lookup) == 1 self.channel_awareness = cast(QComboBox, lookup[0]["widget"]) self.DATA_EXPORT_FIELDS = ( self.COMPANY_SEARCH_INPUT_KEY, self.PERSON_SEARCH_INPUT_KEY, self.CHANNEL_AWARENESS_KEY, ) self.company_widgets: dict[str, QWidget] = {} self.person_widgets: dict[str, QWidget] = {} self.properties_company: tuple[str, ...] = ( "ma_unternehmensname", "ma_strasse", "ma_hausnummer", "ma_plz", "ma_ort", ) self.properties_person: tuple[str, ...] = ( "an_titel", "an_anrede", "an_nachname", "an_vorname", "an_festnetz", "an_mobil", "an_mail", "an_position", ) for entry in self.widget_registry.values(): field = entry["form_field"] widget = entry["widget"] if field.info in self.properties_company: self.company_widgets[field.info] = widget elif field.info in self.properties_person: self.person_widgets[field.info] = widget if DEBUG_SEARCH_WIDGET: button = QPushButton("Print Registry") button.clicked.connect(self.print_registry) main_layout.addWidget(button) button = QPushButton("Reset form") button.clicked.connect(self.reset_form) main_layout.addWidget(button) button = QPushButton("Get form data") button.clicked.connect(self.get_form_data) main_layout.addWidget(button) button = QPushButton("Set form data") button.clicked.connect(self.set_form_data) main_layout.addWidget(button) button = QPushButton("Validate") button.clicked.connect(self.validate_form_data) main_layout.addWidget(button) self.update_company_data() def print_registry(self) -> None: pprint_registry(self.widget_registry) def fill_out_company( self, data: be_init_rec.CompanyInfo, ) -> None: for key, widget in self.company_widgets.items(): if key not in data: raise KeyError( ( f"Key: {key} not found in company info. Add the key " f"to the info property in the form field definition" ) ) set_widget_value(widget, data[key]) def fill_out_person( self, data: be_init_rec.ContactPersonInfo, ) -> None: for key, widget in self.person_widgets.items(): if key not in data: raise KeyError( ( f"Key: {key} not found in company info. Add the key " f"to the info property in the form field definition" ) ) set_widget_value(widget, data[key]) def _clear_company_fields(self) -> None: self.person_search_input.clear() self.channel_awareness.setCurrentIndex(0) for widget in self.company_widgets.values(): widget = cast(QLineEdit, widget) widget.clear() def _clear_person_fields(self) -> None: for widget in self.person_widgets.values(): widget = cast(QLineEdit, widget) widget.clear() def update_company_data(self) -> None: self.company_search_input.clear() self.company_search_input.addItem(DROPDOWN_DEFAULT, None) search_choices = be_init_rec.comp_search_choices() for item, db_index in search_choices: self.company_search_input.addItem(item, db_index) self.company_search_input.setCurrentIndex(-1) def update_person_data( self, ma_id: int | None, ) -> None: self.person_search_input.clear() self.person_search_input.addItem(DROPDOWN_DEFAULT, None) search_choices = be_init_rec.contact_person_search_choices(ma_id, True) for item, db_index in search_choices: self.person_search_input.addItem(item, db_index) self.person_search_input.setCurrentIndex(0) def _selected_company( self, index: int, ) -> None: ma_id = self.company_search_input.itemData(index) if ma_id is None or index == (-1): self._clear_company_fields() return data = be_init_rec.comp_search_get_info( ma_id=ma_id, ) self.fill_out_company(data) self.update_person_data(ma_id) def _selected_person( self, index: int, ) -> None: an_id = self.person_search_input.itemData(index) if an_id is None or index == (-1): self._clear_person_fields() return data = be_init_rec.contact_person_search_get_info( an_id=an_id, ) self.fill_out_person(data) @override def reset_form(self) -> None: reset_form(self.widget_registry) self._clear_company_fields() self._clear_person_fields() @override def validate_form_data(self) -> list[str]: errors = validate_form_data(self.widget_registry) return errors @override def get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry, filter_keys=self.DATA_EXPORT_FIELDS) logger_get_data.debug("Form data CustomWidget:\n%s", pformat(form_data)) return form_data @override def set_form_data( self, data: dict[str, Any], ) -> None: if DEBUG_SEARCH_WIDGET: if not self.export_data: raise RuntimeError("No data. Get form data first.") data = self.export_data set_form_data(self.widget_registry, data, filter_keys=self.DATA_EXPORT_FIELDS) def enhanced_label( base_label: str, add_text: str, add_colon: bool = False, ) -> str: label = base_label.strip().replace("*", "").replace(":", "") if add_text: label = label + f" {add_text}" if add_colon and not label.endswith(":"): label += ":" return label class WidgetRegistryEntry(TypedDict): widget: QWidget form_field: FormField WidgetRegistry: TypeAlias = dict[str, WidgetRegistryEntry] def search_widgets_by_key( widget_registry: WidgetRegistry, key_part: str, ) -> list[WidgetRegistryEntry]: """ needed for custom logic of auto-built forms, search for specific keys and obtain the widget to assign special logic or callbacks to them """ hits: list[WidgetRegistryEntry] = [] for key, entry in widget_registry.items(): if key_part in key: hits.append(entry) return hits class AutoForm(QWidget): """a widget, which is managed by a code-defined field definition collection""" save_clicked_form = Signal() # formular saved (data changed for front page) def __init__( self, cfg: AutoFormConfig, ) -> None: super().__init__() self.cfg = cfg self.setStyleSheet(""" QGroupBox { font-size: 16px; font-weight: bold; border: 1px solid #cbd5e1; /* Heller, moderner Rahmen */ border-radius: 8px; /* Abgerundete Ecken */ margin-top: 15px; /* Platz für die Überschrift schaffen */ padding-top: 15px; /* Abstand zwischen Rahmen und erstem Feld */ } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; /* Überschrift oben links */ padding: 0 5px; /* Etwas Luft links und rechts vom Text */ color: #334155; /* Dunkelgraue Schrift */ } QComboBox { border: 1px solid #cbd5e1; border-radius: 4px; padding: 5px; } QComboBox:disabled { background-color: #f1f5f9; /* Helles System-Grau */ color: #333D4B; /* Gut lesbare, aber gedeckte Schrift */ border: 1px dashed #cbd5e1; /* Gestrichelter Rand wie beim Datum */ } QComboBox::drop-down:disabled { border: none; image: none; } """) # --- special funtionality --- self.no_scroll_filter = NoScrollFilter(self) # --- LAYOUT --- self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) if DEBUG: separator1 = QFrame() separator1.setFrameShape(QFrame.Shape.HLine) separator1.setFrameShadow(QFrame.Shadow.Sunken) self.main_layout.addWidget(separator1) self.registry_button = QPushButton("Ausgabe Widget Registry") self.registry_button.clicked.connect(self._print_registry) self.registry_button.setFixedHeight(35) self.main_layout.addWidget(self.registry_button) self.test_button = QPushButton("Initialisiere Laden") self.test_button.clicked.connect(self.load_data) self.test_button.setFixedHeight(35) self.test_button.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.main_layout.addWidget(self.test_button) button_get = QPushButton("GET DATA") button_get.setFixedHeight(35) button_get.clicked.connect(self.get_form_data) self.main_layout.addWidget(button_get) button_set = QPushButton("SET DATA") button_set.setFixedHeight(35) button_set.clicked.connect(self.set_form_data) self.main_layout.addWidget(button_set) id_field_layout = QHBoxLayout() id_field_layout.setContentsMargins(0, 0, 0, 0) id_field_layout.setSpacing(5) id_field_label = QLabel("ID Datenbank:") self.id_field_input = QLineEdit() id_field_layout.addWidget(id_field_label) id_field_layout.addWidget(self.id_field_input) self.main_layout.addLayout(id_field_layout) button_db_index = QPushButton("Setze DB Index") button_db_index.setFixedHeight(35) button_db_index.clicked.connect(self._set_db_index) self.main_layout.addWidget(button_db_index) separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) self.main_layout.addWidget(separator2) self.main_layout.addSpacing(5) self.main_layout.addSpacing(10) self.top_level_form_layout = QFormLayout() self.main_layout.addLayout(self.top_level_form_layout) self.top_level_form_layout.setSpacing(10) self.form_fields = self.cfg.form_fields self.widget_registry: WidgetRegistry = {} _build_ui_recursively( self.form_fields, self.top_level_form_layout, self.widget_registry, ) # buttons (save and reset) self.add_buttons = self.cfg.add_buttons self.debug_form_data: dict[str, Any] = {} 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.save_data) 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) self.current_id: int = -1 def _print_registry(self) -> None: pprint_registry(self.widget_registry) def _set_db_index(self) -> None: try: index = int(self.id_field_input.text()) except ValueError: index = -1 self.current_id = index logger_gui.debug("Set index to %d", self.current_id) 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 load_data( self, lookup_id: int | None = None, ) -> None: if DEBUG_NO_DATABASE: return logger_auto_form.info(">>>> LOAD CLICKED") if lookup_id is None or lookup_id == 0: lookup_id = self.current_id self.reset_form() logger_auto_form.debug("Lookup ID: %d", lookup_id) if lookup_id > 0: logger_auto_form.debug("Load from DB:") loaded_data = self.cfg.data_get(lookup_id) # TODO remove loading from file (obsolete) else: logger_auto_form.debug("Load from pickled object:") loaded_data = load_pydantic_model_dict_db() logger_auto_form.debug( "Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) ) model = Grunderfassung_Unternehmen(**loaded_data) logger_auto_form.debug("Loaded to Pydantic.") logger_auto_form.debug("Convert to GUI structure...") form_data = model.to_gui() logger_auto_form.debug("Set form data...") logger_auto_form.debug("Form data:\n%s", pformat(form_data)) self.set_form_data(form_data) self.current_id = lookup_id def save_data(self) -> None: self._disable_save() errors = self.validate_form_data() if errors: # errors: abort saving and show pop up window error_text = ( "Bitte füllen Sie die folgenden Pflichtfelder aus:\n\n▸ " + "\n▸ ".join(errors) ) QMessageBox.warning(self, "Fehlende Angaben", error_text) self._enable_save() return # no errors: data can be saved logger_auto_form.info("Erfolg! Alle Daten sind valide.") logger_auto_form.info("Get form data call...") form_data = self.get_form_data() logger_auto_form.debug( "\n\n------------>>>>>>>>> Get form data\n%s", pformat(form_data) ) try: logger_auto_form.debug("------------>>>>>>>>> Call Pydantic") validated_data = self.cfg.model(**form_data) # # TODO add user? except ValidationError as e: # catch errors and show them in GUI error_texts = [] # Pydantic detailed error list for error in e.errors(): logger_gui.error("Error during validation phase:\n%s", pformat(error)) error_field = str(error["loc"][0]) reason = error["msg"] error_texts.append(f"- {error_field}: {reason}") # TODO: Is this needed and feasible? # formatting red # if fehlerhaftes_feld in self.widgets: # self.widgets[fehlerhaftes_feld].setStyleSheet( # "border: 1px solid red; background: #fef2f2;" # ) # tell user what went wrong QMessageBox.warning(self, "Eingabefehler", "\n".join(error_texts)) else: if DEBUG_NO_DATABASE: return # !! this code is only called if the 'try' block was successful # save_pydantic_model_dict_db(validated_data) # TODO save data to database db_data = validated_data.to_db(exclude=self.cfg.ignored_keys) logger_auto_form.debug( ("Form data with 'exlude' (must be saved in the database):\n%s"), pformat(db_data), ) if self.current_id < 0: logger_auto_form.debug("Insert triggered") self.cfg.data_insert(db_data) else: logger_auto_form.debug("Update triggered") self.cfg.data_update(self.current_id, db_data) logger_auto_form.info("Data saved successfully") self.save_clicked_form.emit() # self.reset_form() # TODO check if this behaviour is expected finally: # always re-enable save, even if error occurred self._enable_save() def validate_form_data(self) -> list[str]: return validate_form_data(self.widget_registry) def reset_form(self) -> None: reset_form(self.widget_registry) self.current_id = -1 def get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call get form data:") logger_auto_form.debug("Form Data:\n%s", pformat(form_data)) if DEBUG_GET_SET: self.debug_form_data = form_data return form_data def set_form_data( self, data: dict[str, Any], ) -> None: logger_auto_form.debug("\n\n>>>>>>> [AutoForm] Call set form data:") if DEBUG_GET_SET: data = self.debug_form_data set_form_data(self.widget_registry, data) class DynamicListWidget(QWidget): """ a widget, which can generate and manage an arbitrary number of sub forms. """ def __init__( self, form_fields: Sequence[FormField], label: str = "Eintrag", prefix: str = "", add_empty_entry: bool = False, ): super().__init__() self.form_fields = form_fields self.label = label self.base_label = enhanced_label(label, add_text="") self.prefix = prefix self.widget_registry: WidgetRegistry = {} # layout for group component self.group_box = QGroupBox(label) self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) # inner layout which contains sub forms and buttons self.inner_layout = QVBoxLayout() self.main_layout.addWidget(self.group_box) self.group_box.setLayout(self.inner_layout) # sub forms self.sub_forms: list[SubForm] = [] # button to add more sub forms self.add_btn = QPushButton("+ Hinzufügen") self.add_btn.setStyleSheet( "color: #0369a1; font-weight: bold; border: 1px dashed #0369a1; padding: 5px;" ) self.add_btn.clicked.connect(self.add_entry) self.inner_layout.addWidget(self.add_btn) # add empty sub form as initial value self.add_empty_entry = add_empty_entry if self.add_empty_entry: self.add_entry() def add_entry(self): number_form = len(self.sub_forms) + 1 entry_box = QGroupBox(f"{self.label} {number_form}") entry_layout = QHBoxLayout(entry_box) sub_form = SubForm(entry_box, prefix_parent=self.prefix, index=number_form) # widget_registry: WidgetRegistry = {} form_layout = QFormLayout() _build_ui_recursively( schema=self.form_fields, parent_layout=form_layout, widget_registry=sub_form.registry, # prefix=f"{self.prefix}-[{number_form}]", prefix="", ) self.widget_registry.update(sub_form.registry) # sub_form.registry = widget_registry del_btn = QPushButton("🗑️") del_btn.setFixedSize(30, 30) # Lambda with default parameter to delete exactly this(!) box del_btn.clicked.connect(lambda checked=False, form=sub_form: self.remove_entry(form)) entry_layout.addLayout(form_layout) entry_layout.addWidget(del_btn) self.sub_forms.append(sub_form) self.inner_layout.insertWidget(self.inner_layout.count() - 1, entry_box) self.update_sub_forms() def remove_entry( self, subform_to_remove: SubForm, ): self.inner_layout.removeWidget(subform_to_remove.entry_box) subform_to_remove.entry_box.deleteLater() self.sub_forms.remove(subform_to_remove) self.update_sub_forms() def update_sub_forms(self): update_sub_forms( self.widget_registry, sub_forms=self.sub_forms, base_label=self.base_label, ) def reset_form(self) -> None: while self.sub_forms: self.remove_entry(self.sub_forms[0]) if self.add_empty_entry: self.add_entry() def validate_form_data(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) return errors def get_form_data(self) -> list[dict[str, Any]]: # TODO code cleansing # raw_data = get_form_data(self.widget_registry) # form_data = get_form_data(self.widget_registry) # logger_get_data.debug( # "\n\n\n------- Form data DynamicListWidget:\n%s", pformat(form_data) # ) logger_get_data.info("DynamicListWidget - SubForms") # for sub_form in self.sub_forms: # logger_get_data.info("SubForm index %d", sub_form.index) # pprint_registry(sub_form.registry) form_data = [get_form_data(sub.registry) for sub in self.sub_forms] # ignored_keys: list[str] = [] # for d in form_data: # ignored_keys.extend(d.ignored_keys) # data = [d.data for d in form_data] logger_get_data.debug("##################") logger_get_data.debug("Form data:\n%s", pformat(form_data)) return form_data def set_form_data( self, data: list[dict[str, Any]], ) -> None: while self.sub_forms: self.remove_entry(self.sub_forms[0]) # empty default row if not data and self.add_empty_entry: self.add_entry() return # TODO check removal # for sub_data in data: # self.add_entry() # current_sub_form = self.sub_forms[-1] # registry_entries = search_widgets_by_key( # self.widget_registry, f"-[{current_sub_form.index}]" # ) # for reg_entry in registry_entries: # widget = reg_entry["widget"] # field_def = reg_entry["form_field"] # value = sub_data[field_def.key] # set_widget_value(widget, value) for sub_form_data in data: self.add_entry() current_sub_form = self.sub_forms[-1] set_form_data(current_sub_form.registry, sub_form_data) class DynamicDropdownWidgetNumeric(QWidget): """ A Widget, which can generate and manage an arbitrary number of sub forms with additional information on a combobox selection (combobox). """ def __init__( self, form_fields: Sequence[FormField], label_add_info: str = "Eintrag", prefix: str = "", ): super().__init__() # form_fields = form_fields.children if len(form_fields) == 0 or len(form_fields) > 1: raise ValueError( "Dynamic Dropdown Widget must have only one child, which is a dropdown widget" ) self.combobox_field = form_fields[0] assigned_form_fields = self.combobox_field.children if len(assigned_form_fields) == 0 or len(assigned_form_fields) > 1: raise ValueError( ( "Dynamic Dropdown Widget's dropdown element must have only one " "child, which is a single field definition" ) ) self.type = type self.assigned_form_field = assigned_form_fields[0] self.label_add_info = label_add_info self.prefix = prefix self.widget_registry: WidgetRegistry = {} # layout for group component self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.form_layout = QFormLayout() self.main_layout.addLayout(self.form_layout) self.full_keys = _build_ui_recursively( [self.combobox_field], self.form_layout, self.widget_registry, prefix=f"{self.prefix}", ) dropdown_widget_entry = tuple(self.widget_registry.values())[0] dropdown_widget = dropdown_widget_entry["widget"] assert isinstance(dropdown_widget, QComboBox) self.dropdown_widget = dropdown_widget self.rows_container = QWidget() self.rows_layout = QVBoxLayout(self.rows_container) self.rows_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.addWidget(self.rows_container) self.sub_forms: list[SubForm] = [] self.dropdown_widget.currentTextChanged.connect(self._selection_changed_changed) def _selection_changed_changed( self, text: str, ) -> None: current_count = len(self.sub_forms) value: int if text == DROPDOWN_DEFAULT: value = 0 else: value = int(text) if value > current_count: diff = value - current_count for _ in range(diff): self._add_row() elif value < current_count: diff = current_count - value for _ in range(diff): self._remove_row() def _add_row(self) -> None: number_form = len(self.sub_forms) + 1 container = QWidget() container.setContentsMargins(0, 0, 0, 0) form_layout = QFormLayout(container) form_layout.setContentsMargins(10, 0, 0, 0) sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form) form_field_def = copy.copy(self.assigned_form_field) form_field_def.label = form_field_def.enhanced_label(f"{number_form}") sub_form.full_keys = _build_ui_recursively( schema=[form_field_def], parent_layout=form_layout, widget_registry=sub_form.registry, prefix=f"{self.prefix}", ) self.rows_layout.addWidget(container) self.sub_forms.append(sub_form) self.update_sub_forms() def _remove_row(self) -> None: last_form = self.sub_forms.pop() box_to_remove = last_form.entry_box self.rows_layout.removeWidget(box_to_remove) box_to_remove.deleteLater() self.update_sub_forms() def update_sub_forms(self) -> None: update_sub_forms( self.widget_registry, sub_forms=self.sub_forms, ) def reset_form(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) def validate_form_data(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) return errors def get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) sub_form_data_dicts = (get_form_data(sub.registry) for sub in self.sub_forms) sub_data = merge_dicts_to_lists(sub_form_data_dicts) form_data.update(sub_data) logger_get_data.debug("Form data DynamicDropdownWidget:\n%s", pformat(form_data)) return form_data def set_form_data( self, sub_form_data: dict[str, Any], ) -> None: # delete all rows while self.sub_forms: self._remove_row() # fill in value of combobox field assert len(self.full_keys) == 1 num_subforms: int = -1 for key in self.full_keys: widget = self.widget_registry[key]["widget"] value = sub_form_data[key] set_widget_value(widget, value) if value is None: num_subforms = 0 else: num_subforms = value del sub_form_data[key] logger_get_data.debug(">>>>>>>>> Call set_form_data for DynamicDropdown") logger_get_data.debug("Data before unmerge:%s", pformat(sub_form_data)) assert len(self.sub_forms) == num_subforms if not self.sub_forms: return relevant_keys = self.sub_forms[-1].full_keys # all keys of sub forms are the same sub_data = {key: sub_form_data[key] for key in relevant_keys} logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) if not sub_data: return sub_data = unmerge_dict_to_list(sub_data) logger_get_data.debug(">>>> DynamicDropdownWidget. Sub data:\n%s", pformat(sub_data)) assert len(sub_data) == len(self.sub_forms) for sub_form_data, sub_form in zip(sub_data, self.sub_forms): set_form_data(sub_form.registry, sub_form_data) class DynamicDropdownWidgetOption(QWidget): """ A Widget, which can generate and manage an arbitrary number of sub forms with additional information on a combobox selection (combobox). """ def __init__( self, form_fields: Sequence[FormField], trigger_value: str, label_add_info: str = "Eintrag", prefix: str = "", ): super().__init__() # form_fields = form_fields.children if len(form_fields) == 0 or len(form_fields) > 1: raise ValueError( "Dynamic Dropdown Widget must have only one child, which is a dropdown widget" ) self.combobox_field = form_fields[0] assigned_form_fields = self.combobox_field.children if len(assigned_form_fields) < 1: raise ValueError( ( "Option Dynamic Dropdown Widget dropdown element must have at least one " "child field definition" ) ) self.trigger_value = trigger_value self.assigned_form_fields = assigned_form_fields self.label_add_info = label_add_info self.prefix = prefix self.widget_registry: WidgetRegistry = {} # layout for group component self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.form_layout = QFormLayout() self.main_layout.addLayout(self.form_layout) self.full_keys = _build_ui_recursively( [self.combobox_field], self.form_layout, self.widget_registry, prefix=f"{self.prefix}", ) dropdown_widget_entry = tuple(self.widget_registry.values())[0] dropdown_widget = dropdown_widget_entry["widget"] assert isinstance(dropdown_widget, QComboBox) self.dropdown_widget = dropdown_widget self.rows_container = QWidget() self.rows_layout = QVBoxLayout(self.rows_container) self.rows_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.addWidget(self.rows_container) self.sub_forms: list[SubForm] = [] self.dropdown_widget.currentTextChanged.connect(self._selection_changed_changed) def _selection_changed_changed( self, text: str, ) -> None: current_count = len(self.sub_forms) value: int if text == self.trigger_value: value = 1 else: value = 0 if value > current_count: diff = value - current_count for _ in range(diff): self._add_row() elif value < current_count: diff = current_count - value for _ in range(diff): self._remove_row() def _add_row(self) -> None: number_form = len(self.sub_forms) + 1 container = QWidget() container.setContentsMargins(0, 0, 0, 0) form_layout = QFormLayout(container) form_layout.setContentsMargins(10, 0, 0, 0) sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form) form_field_def = copy.copy(self.assigned_form_fields) # form_field_def.label = form_field_def.enhanced_label(f"{number_form}") sub_form.full_keys = _build_ui_recursively( schema=form_field_def, parent_layout=form_layout, widget_registry=sub_form.registry, prefix=f"{self.prefix}", ) self.rows_layout.addWidget(container) self.sub_forms.append(sub_form) self.update_sub_forms() def _remove_row(self) -> None: last_form = self.sub_forms.pop() box_to_remove = last_form.entry_box self.rows_layout.removeWidget(box_to_remove) box_to_remove.deleteLater() self.update_sub_forms() def update_sub_forms(self) -> None: update_sub_forms( self.widget_registry, sub_forms=self.sub_forms, ) def _get_combined_registry(self, sub_forms_only: bool = False) -> WidgetRegistry: whole_registry: WidgetRegistry = {} if not sub_forms_only: whole_registry = self.widget_registry.copy() for sub in self.sub_forms: whole_registry.update(sub.registry) return whole_registry def reset_form(self) -> None: # resets dynamic content when dropdown is set back to default value self.dropdown_widget.setCurrentIndex(0) def validate_form_data(self) -> list[str]: errors = validate_form_data(self.widget_registry) for form in self.sub_forms: errors.extend(validate_form_data(form.registry)) return errors def get_form_data(self) -> dict[str, Any]: whole_registry = self._get_combined_registry() form_data = get_form_data(whole_registry) # for sub in self.sub_forms: # form_data.update(get_form_data(sub.registry)) logger_get_data.debug( "Form data DynamicDropdownWidgetOption:\n%s", pformat(form_data) ) return form_data def set_form_data( self, data: dict[str, Any], ) -> None: # delete all rows while self.sub_forms: self._remove_row() # fill in value of combobox field assert len(self.full_keys) == 1 num_subforms: int = -1 data = data.copy() for key in self.full_keys: widget = self.widget_registry[key]["widget"] value = data[key] set_widget_value(widget, value) # TODO need to get the correct index logger_get_data.debug("Value: %s", value) if value is None or value != self.trigger_value: num_subforms = 0 else: num_subforms = 1 del data[key] # assert key in whole_registry, "key not in Dynamic DD Option" whole_registry = self._get_combined_registry(sub_forms_only=True) logger_get_data.debug("[DynamicDD-Option] Whole widget registry:\n") pprint_registry(whole_registry) logger_get_data.debug(">>>>>>>>> Call set_form_data for DynamicDropdown") logger_get_data.debug("Data before set of subforms:%s", pformat(data)) assert len(self.sub_forms) == num_subforms, f"{len(self.sub_forms)=}" if not self.sub_forms: return set_form_data(whole_registry, data) class NoScrollFilter(QObject): """disables scrolling in fields""" def eventFilter( self, obj: QObject, event: QEvent, ) -> bool: if event.type() == QEvent.Type.Wheel: # ignored Qt gives event to parent (ScrollArea) event.ignore() # event was handled return True # propagate other events return super().eventFilter(obj, event) class ClickableCell(QFrame): """cell in the table on the startup screen""" clicked = Signal(be_init_rec.FrontpageCompany) def __init__( self, text: str, data_record: be_init_rec.FrontpageCompany, ): super().__init__() self.data_record = data_record self.setStyleSheet(""" ClickableCell { background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; } ClickableCell:hover { background-color: #eff6ff; border: 1px solid #60a5fa; } """) layout = QVBoxLayout(self) label = QLabel(text) label.setWordWrap(True) label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data_record) class HeaderCell(QLabel): def __init__(self, text): super().__init__(text) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setStyleSheet(""" HeaderCell { background-color: #e2e8f0; color: #475569; font-weight: bold; padding: 10px; border-radius: 6px; } """) class NewEntrySelect_view(QWidget): """view for new entry for initial recording ("Grunderfassung")""" back_requested = Signal() # Signal back button company_requested = Signal() # Signal "Unternehmen" person_requested = Signal() # Signal "Individualperson" def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) # Zurück-Button back_btn = QPushButton("← Zurück zur Übersicht") back_btn.setFixedWidth(200) back_btn.clicked.connect(lambda: self.back_requested.emit()) # Platzhalter für die Details self.title_label = QLabel("Wählen Sie den Typ der Grunderfassung") self.title_label.setStyleSheet( "font-size: 24px; font-weight: bold; margin-top: 20px;" ) btn_company = QPushButton("Unternehmen →") btn_company.setFixedWidth(300) btn_company.setFixedHeight(40) btn_company.clicked.connect(lambda: self.company_requested.emit()) btn_person = QPushButton("Individualperson →") btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) btn_person.clicked.connect(lambda: self.person_requested.emit()) btn_person.clicked.connect(lambda: print("Person gewählt")) layout.addWidget(back_btn) layout.addSpacing(15) layout.addWidget(self.title_label) layout.addWidget(btn_company) layout.addWidget(btn_person) CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { "grunderfassung_suche": Grunderfassung_SuchWidget, } FORM_FIELDS_SEARCH_HEAD = [ FormField( "Suche", FormFieldType.EXTENDED_DROPDOWN, required=True, key="un_suche", placeholder="Suche...", ), FormField( "Name Unternehmen/Netzwerkpartner", FormFieldType.TEXT, required=False, key="un_name", readonly=True, info="ma_unternehmensname", ), FormField( "Straße", FormFieldType.TEXT, required=False, key="un_straße", readonly=True, info="ma_strasse", ), FormField( "Hausnummer", FormFieldType.TEXT, required=False, key="un_hausnummer", readonly=True, info="ma_hausnummer", ), FormField( "PLZ", FormFieldType.TEXT, required=False, key="un_PLZ", readonly=True, info="ma_plz", ), FormField( "Ort", FormFieldType.TEXT, required=False, key="un_ort", readonly=True, info="ma_ort", ), FormField( "Suche Ansprechpartner", FormFieldType.EXTENDED_DROPDOWN, required=True, key="person_suche", placeholder="Suche...", ), FormField( "Titel", FormFieldType.TEXT, required=False, key="person_titel", readonly=True, info="an_titel", ), FormField( "Anrede", FormFieldType.TEXT, required=False, key="person_anrede", readonly=True, info="an_anrede", ), FormField( "Name", FormFieldType.TEXT, required=False, key="person_name", readonly=True, info="an_nachname", ), FormField( "Vorname", FormFieldType.TEXT, required=False, key="person_vorname", readonly=True, info="an_vorname", ), FormField( "Telefon", FormFieldType.TEXT, required=False, key="person_telefon", readonly=True, info="an_festnetz", ), FormField( "Mobil", FormFieldType.TEXT, required=False, key="person_mobilfunk", readonly=True, info="an_mobil", ), FormField( "E-Mail", FormFieldType.TEXT, required=False, key="person_email", readonly=True, info="an_mail", ), FormField( "Funktion im Unternehmen", FormFieldType.TEXT, required=False, key="person_funktion", readonly=True, info="an_position", ), FormField( "Wie sind Sie auf uns aufmerksam geworden?", FormFieldType.DROPDOWN, required=False, key="kanal_aufmerksamkeit", options=[ ("Agentur für Arbeit", None), ("Ausländerbehörde", None), ("Jobcenter", None), ("Freunde/Familie", None), ("Anerkennungsstelle", None), ("Beratungsstelle", None), ("Internet", None), ("Arbeitgeber", None), ("Bildungsdienstleister", None), ("Welcome-Mappe", None), ("Newsletter WFE", None), ("Newsletter RM", None), ("Sonstiges", None), ], ), ] FORM_FIELDS_CONTACT_PERSON = [ FormField( "Name Unternehmen/Netzwerkpartner (pre-filled von Suche)", FormFieldType.TEXT, key="KP_name_partner", required=False, placeholder="Text wird nach gewähltem Unternehmen angezeigt", readonly=True, ), FormField( "Titel", FormFieldType.TEXT, key="KP_titel", required=False, tooltip=( "* nur wenn anrufende Person oder kontaktaufnehmende Person " "nicht die zu beratende Person ist" ), ), FormField( "Anrede_Anschrift", FormFieldType.TEXT, key="KP_anrede_anschrift", required=True, ), FormField( "Name", FormFieldType.TEXT, key="KP_name", required=True, ), FormField( "Vorname", FormFieldType.TEXT, key="KP_vorname", required=False, ), FormField( "Festnetznummer", FormFieldType.TEXT, key="KP_festnetznummer", required=False, ), FormField( "Mobilfunknummer", FormFieldType.TEXT, key="KP_mobilfunknummer", required=False, ), FormField( "E-Mail", FormFieldType.TEXT, key="KP_email", required=False, ), FormField( "Funktion/Beziehung zur beratenden Person", FormFieldType.TEXT, key="KP_funktion_beziehung", required=False, ), FormField( "Adresse", FormFieldType.LONGTEXT, key="KP_adresse", required=False, ), ] FORM_FIELDS_MASTER_DATA = [ FormField( "Titel", FormFieldType.TEXT, key="titel", required=False, tooltip=( "* nur wenn anrufende Person oder kontaktaufnehmende Person " "nicht die zu beratende Person ist" ), ), FormField( "Anrede", FormFieldType.TEXT, key="anrede_anschrift", required=True, ), FormField( "Name", FormFieldType.TEXT, key="name", required=True, ), FormField( "Vorname", FormFieldType.TEXT, key="vorname", required=False, ), FormField( "Geburtsdatum", FormFieldType.DATE, key="geburtsdatum", required=False, tooltip=( "* Wichtig zu erfragen, da u.a. Mindestgehaltsschwelle davon abhängt " "(Regelung bei Ü45 Jahre)" ), ), FormField( "Herkunftsland", FormFieldType.EXTENDED_DROPDOWN, key="herkunftsland", required=True, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Staatsangehörigkeit", FormFieldType.EXTENDED_DROPDOWN, key="staatsangehoerigkeit", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Rückkehrer", FormFieldType.DROPDOWN, key="rueckkehrer", required=False, options=[("ja", None), ("nein", None)], tooltip=("* Wichtig zu erfragen aufgrund eventueller EU-Freizügigkeitsregelung"), ), FormField( "Wo befindet sich die Person?", FormFieldType.DROPDOWN, key="aufenthaltsort", required=True, options=[("Inland", None), ("Ausland EU/EWR", None), ("Ausland Drittstaat", None)], ), FormField( "Straße", FormFieldType.TEXT, key="strasse", required=False, ), FormField( "Hausnummer", FormFieldType.TEXT, key="hausnummer", required=False, ), FormField( "PLZ", FormFieldType.TEXT, key="PLZ", required=False, ), FormField( "Ort", FormFieldType.TEXT, key="ort", required=False, ), FormField( "Bundesland", FormFieldType.DROPDOWN, key="bundesland", required=False, options=GERMAN_STATE_LIST.for_dropdown, tooltip=( "nur wenn Inland angegeben und die Angabe zieht es in keine Dokumente " "rüber! Liste Bundesländer verwenden" ), ), FormField( "Land", FormFieldType.EXTENDED_DROPDOWN, key="land", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, ), FormField( "Festnetznummer", FormFieldType.TEXT, key="festnetznummer", required=False, ), FormField( "Mobilfunknummer", FormFieldType.TEXT, key="mobilfunknummer", required=False, ), FormField( "E-Mail", FormFieldType.TEXT, key="email", required=False, ), FormField( "Familienstand", FormFieldType.TEXT, key="familienstand", required=False, tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", ), FormField( "Anzahl Kinder", FormFieldType.DYNAMIC_DROPDOWN_NUMERIC, required=False, tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl_kinder", children=[ FormField( "Anzahl Kinder", FormFieldType.DROPDOWN, required=False, options=[(str(x), None) for x in range(11)], tooltip="* Wichtig zu erfragen aufgrund Lebensunterhaltssicherung", key="anzahl", children=[ FormField("Alter Kind", FormFieldType.TEXT, key="alter"), ], ), ], ), ] FORM_FIELDS_ADDITIONAL_DATA = [ FormField( "Deutsch als Kommunikationssprache", FormFieldType.DROPDOWN, required=False, key="WI_deutsch_sprache", options=[ ("nein", None), ("ja, als Muttersprache", None), ("ja, als Fremdsprache", None), ], ), FormField( "Aufenthaltstitel", FormFieldType.DROPDOWN, required=False, key="WI_aufenthaltstitel", options=[ ("anerkannter Flüchtling §§ 22 - 26 AufenthG", None), ("Aufenthaltsgestattung §55 AufenthG", None), ("Blaue Karte EU § 18g AufenthG", None), ("BüMA (Bescheinigung über Meldung als Asylsuchender)", None), ("Duldung § 60 AufenthG", None), ("bisher kein Aufenthaltstitel", None), ("Deutscher", None), ("familiäre Gründe §§ 27 - 36 AufenthG", None), ("Niederlassungserlaubnis §9 AufenthG", None), ("Staatsbürger EUR/EWR/CH", None), ("Aufenthalt für Ausbildung §§ 16 - 17 AufenthG", None), ("Aufenthalt für Erwerbstätigkeit §§ 18- 21 AufenthG", None), ("Chancenaufenthaltsrecht §104c AufenthG", None), ("Sonstiges", None), ], tooltip="sofern nicht bekannt, unbedingt einfordern", ), FormField( "Gültigkeit Aufenthaltsstatus", FormFieldType.DATE, required=False, key="WI_gueltigkeit_aufenthaltstitel", ), FormField( "Arbeitsstatus aktuell", FormFieldType.DROPDOWN, required=False, key="WI_arbeitsstatus", options=[ ("Arbeitslos", None), ("Ausbildung/Qualifizierung Inland", None), ("geringfügig beschäftigt", None), ("in Anstellung Inland", None), ("selbstständig Inland", None), ("Ausbildung/Qualifizierung Ausland", None), ("in Anstellung Ausland", None), ("selbstständig Ausland", None), ], ), FormField( "Gemeldet bei Institutionen ", FormFieldType.DROPDOWN, required=False, key="WI_meldung_institution", options=[ ("bei keiner", None), ("Jobcenter mit Leistungsbezug", None), ("Jobcenter ohne Leistungsbezug", None), ("Sozialamt mit Leistungsbezug", None), ("Sozialamt ohne Leistungsbezug", None), ("Agentur für Arbeit mit Leistungsbezug", None), ("Agentur für Arbeit ohne Leistungsbezug", None), ], ), ] FORM_FIELDS_SCHOOL = [ FormField("Abschluss", FormFieldType.TEXT, required=False, key="SB_abschluss"), FormField( "Abschlussgrad laut Dokument", FormFieldType.TEXT, required=False, key="SB_abschlussgrad", ), FormField( "Schule", FormFieldType.TEXT, required=False, key="SB_schule", ), FormField( "Ort", FormFieldType.TEXT, required=False, key="SB_ort", ), FormField( "Land", FormFieldType.EXTENDED_DROPDOWN, key="SB_land", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, ), FormField("Abschlussjahr", FormFieldType.TEXT, required=False, key="SB_abschlussjahr"), FormField( "Bemerkungsfeld", FormFieldType.TEXT, required=False, key="SB_bemerkungsfeld", ), ] FORM_FIELDS_HIGHER_EDUCATION = [ FormField( "Anerkennung", FormFieldType.TEXT, required=False, key="HB_anerkennung", ), FormField( "Abschlussgrad", FormFieldType.TEXT, required=False, key="HB_abschlussgrad", tooltip=( "bitte den Titel eingeben z.B. Doktor, Diplom oder " "Betriebswirt (Fachschulabschluss)" ), ), FormField( "Abschlussgrad laut Dokument", FormFieldType.TEXT, required=False, key="HB_abschlussgrad_dokument", ), FormField( "Hochschule / Ausbildungsbetrieb / Berufsschule", FormFieldType.TEXT, required=False, key="HB_organisation", ), FormField( "Beruf / Fachrichtung", FormFieldType.TEXT, required=False, key="HB_beruf", tooltip=( "bitte spezifizieren z.B. Allgemeinmedizin, Ingenieur Maschinenbau, " "technischer Betriebswirt Datenverarbeitung" ), ), FormField( "Land", FormFieldType.EXTENDED_DROPDOWN, key="HB_land", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, ), FormField( "Ort", FormFieldType.TEXT, required=False, key="HB_ort", ), FormField( "Abschlussjahr", FormFieldType.TEXT, required=False, key="HB_abschlussjahr", ), FormField( "Bemerkungsfeld", FormFieldType.TEXT, required=False, key="HB_bemerkungsfeld", tooltip="z.B. Promotionen oder den Studiengang angeben", ), ] FORM_FIELDS_WORK_EXPERIENCE = [ FormField( "Branche", FormFieldType.DROPDOWN, required=False, key="AE_branche", options=[ (part, None) for part in ( "Metallerzeugung & -bearbeitung", "Elektro, Energie, Chemie", "IT & Software", "Kunststoff, Papier, Textil", "Logistik, Verkehr, Transport", "Handwerk, Bau, Grüne Berufe", "Gesundheit & Pflege", "Tourismus & Gastronomie", "Handel", "Bildung & Soziales", "Entwicklung, Planung, Qualität", "Administration, Finanzen, Verwaltung", "Marketing, Design, Vertrieb", "Einkauf, Lager, Wartung", "Sonstige", "Keine Schwerpunkte, branchenübergreifende Rekrutierung", ) ], ), FormField( "Berufsbezeichnung/Tätigkeit", FormFieldType.TEXT, required=False, key="AE_bezeichnung", ), FormField( "Funktion", FormFieldType.DROPDOWN, required=False, key="AE_funktion", options=[ ("Auszubildender", None), ("Fachkraft", None), ("Hilfskraft", None), ("Akademiker", None), ("Führungskraft", None), ("Praktikant", None), ("FSJ/BFD", None), ("Elternzeit", None), ("Sabbatical", None), ("Sonstiges", None), ], ), FormField( "Unternehmen", FormFieldType.TEXT, required=False, key="AE_unternehmen", ), FormField( "Land", FormFieldType.EXTENDED_DROPDOWN, key="AE_land", required=False, placeholder="Suche...", options=COUNTRY_LIST.for_dropdown, ), FormField( "Zeitspanne (von ... bis ...)", FormFieldType.TEXT, required=False, key="AE_zeitspanne", ), FormField( "Beschäftsigungsart", FormFieldType.DROPDOWN, required=False, key="AE_beschaeftigungsart", options=[ ("Vollzeit", None), ("Teilzeit", None), ("Sonstiges", None), ], tooltip="Minijob, Praktikum, Wehrdienst, soziale Dienste", ), FormField( "Bemerkungsfeld", FormFieldType.TEXT, required=False, key="AE_bemerkungsfeld", ), ] FORM_FIELDS_LANGUAGES = [ FormField( "Sprache", FormFieldType.TEXT, required=False, key="SP_sprache", ), FormField( "Niveau", FormFieldType.DROPDOWN, required=False, key="SP_niveau", options=[ ("A1", None), ("A2", None), ("B1", None), ("B2", None), ("C1", None), ("C2", None), ], ), FormField( "Nachweis", FormFieldType.DYNAMIC_DROPDOWN_OPTION, key="", trigger_value="vorhanden", children=[ FormField( "Nachweis", FormFieldType.DROPDOWN, required=False, options=[("vorhanden", None), ("nicht vorhanden", None)], key="SP_nachweis", children=[ FormField( "Art des Nachweises", FormFieldType.TEXT, required=False, key="SP_art_nachweis", ), FormField( "Datum des Nachweises", FormFieldType.DATE, required=False, key="SP_datum_nachweis", ), ], ), ], ), # FormField( # "Nachweis", # FormFieldType.DROPDOWN, # required=False, # key="SP_nachweis", # options=[ # ("vorhanden", None), # ("nicht vorhanden", None), # ], # ), # FormField( # "Art des Nachweises (NUR WENN VORHANDEN)", # FormFieldType.TEXT, # required=False, # key="SP_art_nachweis", # ), # FormField( # "Datum des Nachweises (NUR WENN VORHANDEN)", # FormFieldType.DATE, # required=False, # key="SP_datum_nachweis", # ), ] FORM_FIELDS = [ FormField( "Ersteintrag Datum", FormFieldType.TEXT_DATETIME, required=False, key="Metadaten_erstellung", readonly=True, ignore_get_data=True, ), FormField( "Aktualisierung Datum", FormFieldType.TEXT_DATETIME, required=False, key="Metadaten_aktualisierung", readonly=True, ignore_get_data=True, ), FormField( "Aktualisierung Nutzer", FormFieldType.TEXT, required=False, key="Metadaten_nutzer", readonly=True, ), FormField( "Fallnummer", FormFieldType.TEXT, required=True, key="Grunderfassung_fallnummer", ), FormField( "Notizen", FormFieldType.LONGTEXT, required=False, key="Grunderfassung_notiz", ), FormField( "Suche", FormFieldType.CUSTOM, custom_widget="grunderfassung_suche", key="Partnersuche", children=FORM_FIELDS_SEARCH_HEAD, ), FormField( "Status && Projektrelevanz", FormFieldType.GROUP, key="Projektrelevanz", children=[ FormField( "Projektrelevanz", FormFieldType.DYNAMIC_DROPDOWN_OPTION, key="", trigger_value="ja", children=[ FormField( "Relevanz", FormFieldType.DROPDOWN, required=True, options=[("ja", None), ("nein", None)], key="relevanz", children=[ FormField( "Förderperiode", FormFieldType.TEXT, key="foerderperiode" ), ], ), ], ), ], ), FormField( "Daten Kontaktperson", FormFieldType.GROUP, key="Kontaktperson", children=FORM_FIELDS_CONTACT_PERSON, ), FormField( "Stammdaten", FormFieldType.GROUP, key="Stammdaten", children=FORM_FIELDS_MASTER_DATA, ), FormField( "Weitere Informationen", FormFieldType.GROUP, key="WeitereInfos", children=FORM_FIELDS_ADDITIONAL_DATA, ), FormField( "Schulbildung", FormFieldType.DYNAMIC_LIST, children=FORM_FIELDS_SCHOOL, key="Schulbildung", ), FormField( "Studium/Ausbildung", FormFieldType.DYNAMIC_LIST, children=FORM_FIELDS_HIGHER_EDUCATION, key="HoehereBildung", ), FormField( "Arbeitserfahrung", FormFieldType.DYNAMIC_LIST, children=FORM_FIELDS_WORK_EXPERIENCE, key="Arbeitserfahrung", ), FormField( "Sprachkenntnisse", FormFieldType.DYNAMIC_LIST, children=FORM_FIELDS_LANGUAGES, key="Sprachkenntnisse", ), ] CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung_Unternehmen, data_insert=be_init_rec.insert_initial_recording, data_update=be_init_rec.update_initial_recording, data_get=be_init_rec.get_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", ), form_fields=FORM_FIELDS, ) # TODO clean code class PageFormCompany(QWidget): back_main_requested = Signal() # back to main page back_requested = Signal() # back button save_clicked_form = Signal() # form saved (data changed for front page) def __init__(self): super().__init__() # Hauptlayout der Seite outer_layout = QHBoxLayout(self) vert_layout = QVBoxLayout() # main_layout.setContentsMargins(0, 0, 0, 0) outer_layout.addStretch(1) outer_layout.addLayout(vert_layout, stretch=100) # outer_layout.addWidget(scroll_area, stretch=100) outer_layout.addStretch(1) # Optional: Damit der Container oben am Rand klebt outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # --- HEADER --- header_container = QWidget() header_container.setMinimumWidth(700) header_container.setMaximumWidth(1000) header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) header_layout = QVBoxLayout(header_container) header_layout.setContentsMargins(0, 0, 0, 10) back_btn_main = QPushButton("← Zurück zur Übersicht") back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_main.setMinimumWidth(200) back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") back_btn_step.clicked.connect(lambda: self.back_requested.emit()) back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) back_btn_step.setMinimumWidth(200) back_btn_step.setMaximumWidth(200) title = QLabel("Grunderfassung Unternehmen") title.setStyleSheet("font-size: 20px; font-weight: bold;") header_layout.setSpacing(5) header_layout.addWidget(back_btn_step) header_layout.addWidget(back_btn_main) header_layout.addWidget(title) vert_layout.addWidget(header_container) # --- HAUPTINHALT --- container = QWidget() # SCROLL-BEREICH scroll_area = QScrollArea() scroll_area.setWidgetResizable( True ) # WICHTIG: Erlaubt dem Grid im Inneren, sich an die Breite anzupassen scroll_area.setMinimumWidth(700) scroll_area.setMaximumWidth(1000) scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Optional: Rahmen der ScrollArea entfernen, damit es "flacher" und moderner aussieht scroll_area.setFrameShape(QFrame.Shape.NoFrame) scroll_area.setWidget(container) # vert_layout.addSpacing(20) vert_layout.addWidget(scroll_area) container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) # --- AUTO FORM LAYOUT --- container_layout.addSpacing(20) # title = QLabel("--- Automatische Form ---") # title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold; # container_layout.addWidget(title) # container_layout.addWidget(MyFormPart(FORM_FIELD_DEF, "Test-Gruppe")) self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_UNTERNEHMEN) container_layout.addWidget(self.auto_form) self.auto_form.save_clicked_form.connect(lambda: self.save_clicked_form.emit()) container_layout.addSpacing(30) def reset_form(self) -> None: self.auto_form.reset_form() def clear_layout( layout: QLayout | None, ) -> None: if layout is None: return # for idx in range(start_idx, layout.count()): while layout.count(): child = layout.takeAt(0) if child is None: continue widget = child.widget() if widget is not None: widget.deleteLater() elif child.layout(): clear_layout(child.layout()) # 2. Das Hauptfenster mit dem Grid-Layout class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Master") self.resize(1800, 1000) # MENU self.create_menu() # STACK: stack to change between 'sites' self.stack = QStackedWidget() self.setCentralWidget(self.stack) # MAIN PAGE: table view self.main_page = self.setup_main_page() self.stack.addWidget(self.main_page) # SITE: add new entries for 'Grunderfassung' self.new_entry_select = NewEntrySelect_view() self.new_entry_select.back_requested.connect(self.show_main_page) self.new_entry_select.company_requested.connect(self.show_company_page) self.stack.addWidget(self.new_entry_select) # SITE: 'Grunderfassung Unternehmen' self.company_recording_page = PageFormCompany() self.company_recording_page.back_main_requested.connect(self.show_main_page) self.company_recording_page.back_requested.connect(self.show_new_entry_select) self.company_recording_page.save_clicked_form.connect(self.update_grid) self.stack.addWidget(self.company_recording_page) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle main_widget = QWidget() outer_layout = QHBoxLayout(main_widget) vert_layout = QVBoxLayout() # add buttons new_btn = QPushButton("Neu →") new_btn.setFixedWidth(100) new_btn.setFixedHeight(40) new_btn.clicked.connect(self.show_new_entry_select) vert_layout.addWidget(new_btn) if DEBUG: update_btn = QPushButton("UPDATE") update_btn.setFixedWidth(100) update_btn.setFixedHeight(40) update_btn.clicked.connect(self.update_grid) clear_btn = QPushButton("CLEAR") clear_btn.setFixedWidth(100) clear_btn.setFixedHeight(40) clear_btn.clicked.connect(self._clear_layout) vert_layout.addWidget(update_btn) vert_layout.addWidget(clear_btn) separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) separator.setFrameShadow(QFrame.Shadow.Sunken) vert_layout.addWidget(separator) # container for table or grid container = QWidget() scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setMinimumWidth(700) scroll_area.setMaximumWidth(1500) scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # delete frame of ScrollArea to get a flat look scroll_area.setFrameShape(QFrame.Shape.NoFrame) scroll_area.setWidget(container) vert_layout.addSpacing(20) vert_layout.addWidget(scroll_area) # springs left and right of container to center the it outer_layout.addStretch(1) outer_layout.addLayout(vert_layout, stretch=100) outer_layout.addStretch(1) outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Wir geben dem Container ein vertikales Layout container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) # grid for table without direct container self.header_grid = QGridLayout() self.header_grid.setColumnStretch(0, 1) self.header_grid.setColumnStretch(1, 1) self.header_grid.setColumnStretch(2, 1) self.header_grid.setColumnStretch(3, 1) self.header_grid.setColumnMinimumWidth(4, 100) self.grid = QGridLayout() self.grid.setSpacing(10) self.grid.setColumnStretch(0, 1) self.grid.setColumnStretch(1, 1) self.grid.setColumnStretch(2, 1) self.grid.setColumnStretch(3, 1) self.grid.setColumnMinimumWidth(4, 100) # add grid to vertical layout container_layout.addLayout(self.header_grid) container_layout.addLayout(self.grid) # spring below the layout to push it to top container_layout.addStretch() self.current_row = 0 headers = [ "UN/ NWP/ Kontaktperson", "Individualberatung", "Pauschalberatung", "Verlaufsprotokoll", "Datum", ] for col_idx, title in enumerate(headers): self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) # self.current_row += 1 self.update_grid() return main_widget def _clear_layout(self) -> None: clear_layout(self.grid) def update_grid(self) -> None: clear_layout(self.grid) data = be_init_rec.get_company_list() for entry in data: self.add_row_to_grid(entry) def add_row_to_grid(self, entry: be_init_rec.FrontpageCompany): row = self.current_row # NAME cell_name = ClickableCell(entry.name, entry) cell_name.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_name, row, 0) # DATE cell_date_name = entry.Metadaten_aktualisierung.date().strftime(DATE_FMT) cell_date = ClickableCell(cell_date_name, entry) cell_date.clicked.connect(self.goto_initial_recording) self.grid.addWidget(cell_date, row, 4) # self.grid.addWidget(ClickableCell(entry["c2"], entry), row, 1) # c3_value = entry.get("c3") # if c3_value: # self.grid.addWidget(ClickableCell(c3_value, entry), row, 2) # else: # empty_box = QFrame() # empty_box.setStyleSheet( # "QFrame { background-color: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 8px; }" # ) # self.grid.addWidget(empty_box, row, 2) # self.grid.addWidget(ClickableCell(entry.get("c4", ""), entry), row, 3) # self.grid.addWidget(ClickableCell(entry.get("date", ""), entry), row, 4) self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen def goto_initial_recording( self, data: be_init_rec.FrontpageCompany, ): self.company_recording_page.auto_form.load_data(data.erfassung_id) self.stack.setCurrentWidget(self.company_recording_page) # 1. Daten an die Detail-Seite übergeben # self.detail_page.update_content(data) # 2. Auf die Detail-Seite umblättern # self.stack.setCurrentWidget(self.detail_page) def show_main_page(self): # Zurück zur Tabelle blättern self.stack.setCurrentWidget(self.main_page) def show_new_entry_select(self): self.stack.setCurrentWidget(self.new_entry_select) def show_company_page(self): self.company_recording_page.reset_form() self.stack.setCurrentWidget(self.company_recording_page) # --- MENÜ LOGIK --- def create_menu(self): menu_bar = self.menuBar() # file menu file_menu = menu_bar.addMenu("Datei") exit_action = QAction("Beenden", self) exit_action.setShortcut("Ctrl+Q") exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # help menu help_menu = menu_bar.addMenu("Hilfe") about_action = QAction("Über", self) help_menu.addAction(about_action) if __name__ == "__main__": app = QApplication(sys.argv) app.setStyleSheet(QSS) window = MainWindow() window.show() sys.exit(app.exec())