From 44402af0b734bd99d14f8431433fe75da754ec73 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 09:44:06 +0200 Subject: [PATCH 01/12] major refactoring --- src/wce_crm/custom_widget_registry.py | 9 + src/wce_crm/data_models.py | 297 +++++++ src/wce_crm/form_defs.py | 1109 +++++++++++++++++++++++++ 3 files changed, 1415 insertions(+) create mode 100644 src/wce_crm/custom_widget_registry.py create mode 100644 src/wce_crm/data_models.py create mode 100644 src/wce_crm/form_defs.py diff --git a/src/wce_crm/custom_widget_registry.py b/src/wce_crm/custom_widget_registry.py new file mode 100644 index 0000000..f1c6d8a --- /dev/null +++ b/src/wce_crm/custom_widget_registry.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Final + +CUSTOM_WIDGET_NAMES: Final[frozenset] = frozenset( + [ + "grunderfassung_suche", + ] +) diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py new file mode 100644 index 0000000..9575ea7 --- /dev/null +++ b/src/wce_crm/data_models.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import datetime +import json +from typing import Annotated, Any, Final + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + EmailStr, + Field, + field_validator, + model_validator, +) + +ValidAge = Annotated[int, Field(ge=0, le=99)] + + +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 + + +COLUMN_SEP: Final[str] = "__" + + +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) + + +class Grunderfassung(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 + Metadaten_wiedereintrittsdatum: datetime.date | None = None + Grunderfassung_fallnummer: str + Grunderfassung_notiz: str | None + + Partnersuche: Grunderfassung_PartnerSuche | None = None + 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 | None + person_suche: int | None + kanal_aufmerksamkeit: str | None + + +class Grunderfassung_Projektrelevanz(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + relevanz: str + foerderperiode: str | None = None + + +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 + + +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 diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py new file mode 100644 index 0000000..365827c --- /dev/null +++ b/src/wce_crm/form_defs.py @@ -0,0 +1,1109 @@ +from __future__ import annotations + +import dataclasses as dc +import enum +import uuid +from collections.abc import Sequence +from typing import Any, Final + +import babel + +from wce_crm.custom_widget_registry import CUSTOM_WIDGET_NAMES + + +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 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_WIDGET_NAMES + ): + 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 + + +@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 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() + + +INITREC_COMP_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), + ], + ), +] + +INITREC_CONTACT_PERSON = [ + FormField( + "Name Unternehmen/Netzwerkpartner (vorausgefüllt 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, + ), +] + + +INITREC_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"), + ), + 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"), + ], + ), + ], + ), +] + + +INITREC_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), + ], + ), +] + + +INITREC_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", + ), +] + + +INITREC_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", + ), +] + + +INITREC_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", + ), +] + + +INITREC_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", + ), + ], + ), + ], + ), +] + + +INITREC_COMP = [ + 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=INITREC_COMP_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=INITREC_CONTACT_PERSON, + ), + FormField( + "Stammdaten", + FormFieldType.GROUP, + key="Stammdaten", + children=INITREC_MASTER_DATA, + ), + FormField( + "Weitere Informationen", + FormFieldType.GROUP, + key="WeitereInfos", + children=INITREC_ADDITIONAL_DATA, + ), + FormField( + "Schulbildung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_SCHOOL, + key="Schulbildung", + ), + FormField( + "Studium/Ausbildung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_HIGHER_EDUCATION, + key="HoehereBildung", + ), + FormField( + "Arbeitserfahrung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_WORK_EXPERIENCE, + key="Arbeitserfahrung", + ), + FormField( + "Sprachkenntnisse", + FormFieldType.DYNAMIC_LIST, + children=INITREC_LANGUAGES, + key="Sprachkenntnisse", + ), +] + +INITREC_PERSON = [ + 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( + "Wiedereintrittsdatum", + FormFieldType.DATE, + required=False, + key="Metadaten_wiedereintrittsdatum", + readonly=False, + ), + FormField( + "Fallnummer", + FormFieldType.TEXT, + required=True, + key="Grunderfassung_fallnummer", + ), + FormField( + "Notizen", + FormFieldType.LONGTEXT, + required=False, + key="Grunderfassung_notiz", + ), + 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=INITREC_CONTACT_PERSON, + ), + FormField( + "Stammdaten", + FormFieldType.GROUP, + key="Stammdaten", + children=INITREC_MASTER_DATA, + ), + FormField( + "Weitere Informationen", + FormFieldType.GROUP, + key="WeitereInfos", + children=INITREC_ADDITIONAL_DATA, + ), + FormField( + "Schulbildung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_SCHOOL, + key="Schulbildung", + ), + FormField( + "Studium/Ausbildung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_HIGHER_EDUCATION, + key="HoehereBildung", + ), + FormField( + "Arbeitserfahrung", + FormFieldType.DYNAMIC_LIST, + children=INITREC_WORK_EXPERIENCE, + key="Arbeitserfahrung", + ), + FormField( + "Sprachkenntnisse", + FormFieldType.DYNAMIC_LIST, + children=INITREC_LANGUAGES, + key="Sprachkenntnisse", + ), +] -- 2.34.1 From 4a1d5d464702f09a8dc4e003d89eb8df99d90825 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 09:45:26 +0200 Subject: [PATCH 02/12] change database structure for initial recording of individual persons --- prototypes/db_alter.py | 19 +++++++++++++++++++ prototypes/tests.py | 6 +++--- src/wce_crm/db.py | 11 ++++++----- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 prototypes/db_alter.py diff --git a/prototypes/db_alter.py b/prototypes/db_alter.py new file mode 100644 index 0000000..2cf0ba5 --- /dev/null +++ b/prototypes/db_alter.py @@ -0,0 +1,19 @@ +# %% +import sqlalchemy as sql + +from wce_crm import db + +# %% +stmt = sql.text( + "ALTER TABLE grunderfassung_unternehmen ADD COLUMN Metadaten_wiedereintrittsdatum DATE" +) + +with db.ENGINE.begin() as conn: + conn.execute(stmt) + +# %% +stmt = sql.text("ALTER TABLE grunderfassung_unternehmen RENAME TO grunderfassung") + +with db.ENGINE.begin() as conn: + conn.execute(stmt) +# %% diff --git a/prototypes/tests.py b/prototypes/tests.py index 3a1ab35..1b5102f 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -30,8 +30,8 @@ engine = sql.create_engine(f"sqlite:///{str(db_path)}") engine_crm = sql.create_engine(f"sqlite:///{str(crm_path)}") # %% stmt = sql.select( - db.grunderfassung_unternehmen.c.erfassung_id, - db.grunderfassung_unternehmen.c.Partnersuche__un_suche, + db.grunderfassung.c.erfassung_id, + db.grunderfassung.c.Partnersuche__un_suche, ) with engine.connect() as conn: @@ -44,7 +44,7 @@ for r in res: # %% -backend.get_company_list() +backend.front_get_company_list() # %% diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 5cc67cb..fb380c6 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -323,8 +323,8 @@ def get_ext_crm_contact_person( # df_contact_person = get_ext_crm_contact_person(None) DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) -grunderfassung_unternehmen: sql.Table = Table( - "grunderfassung_unternehmen", +grunderfassung: sql.Table = Table( + "grunderfassung", md_main, Column( "erfassung_id", @@ -346,6 +346,7 @@ grunderfassung_unternehmen: sql.Table = Table( onupdate=lambda: datetime.datetime.now(datetime.UTC), ), Column("Metadaten_nutzer", sql.String(20), nullable=True), + Column("Metadaten_wiedereintrittsdatum", sql.Date, nullable=True, default=None), Column("Arbeitserfahrung", sql.Text, nullable=True), Column("Grunderfassung_fallnummer", sql.Text, nullable=True), Column("Grunderfassung_notiz", sql.Text, nullable=True), @@ -362,9 +363,9 @@ grunderfassung_unternehmen: sql.Table = Table( ), # TODO: check if needed when set by trigger Column("Kontaktperson__KP_titel", sql.Text, nullable=True), Column("Kontaktperson__KP_vorname", sql.Text, nullable=True), - Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True), - Column("Partnersuche__person_suche", sql.Integer, nullable=True), - Column("Partnersuche__un_suche", sql.Integer, nullable=True), + Column("Partnersuche__kanal_aufmerksamkeit", sql.Text, nullable=True, default=None), + Column("Partnersuche__person_suche", sql.Integer, nullable=True, default=None), + Column("Partnersuche__un_suche", sql.Integer, nullable=True, default=None), Column("Projektrelevanz__relevanz", sql.Text, nullable=True), Column("Projektrelevanz__foerderperiode", sql.Text, nullable=True), Column("Schulbildung", sql.Text, nullable=True), -- 2.34.1 From 1f75801fc536af4ca0041406ad11d8a4d5626513 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 09:46:04 +0200 Subject: [PATCH 03/12] add initial recording of individual persons with changed database interaction --- src/wce_crm/backend/backend.py | 55 +- src/wce_crm/gui.py | 1520 +++++--------------------------- 2 files changed, 250 insertions(+), 1325 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index a6e669e..8ad2161 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -82,7 +82,7 @@ def _transform_for_gui_output( return q.collect() -def comp_search_choices() -> tuple[tuple[str, int], ...]: +def initrec_comp_search_choices() -> tuple[tuple[str, int], ...]: # TODO no reload functionality logger.debug("[Call backend] comp_search_choices") q = db.DF_CRM_MASTER.lazy() @@ -97,7 +97,7 @@ def comp_search_choices() -> tuple[tuple[str, int], ...]: return tuple(zip(df["dedupl"], df["ma_id"])) -def comp_search_get_info( +def initrec_comp_search_get_info( ma_id: int, ) -> CompanyInfo: logger.debug("[Call backend] comp_search_get_info") @@ -109,7 +109,7 @@ def comp_search_get_info( return cast(CompanyInfo, df.row(0, named=True)) -def contact_person_search_choices( +def initrec_comp_contact_person_search_choices( ma_id: int | None, use_both_names: bool, ) -> tuple[tuple[str, int], ...]: @@ -137,7 +137,7 @@ def contact_person_search_choices( return tuple(zip(df["dedupl"], df["an_id"])) -def contact_person_search_get_info( +def initrec_comp_contact_person_search_get_info( an_id: int, ) -> ContactPersonInfo: logger.debug("[Call backend] contact_person_search_get_info") @@ -149,36 +149,32 @@ def contact_person_search_get_info( return cast(ContactPersonInfo, df.row(0, named=True)) -def insert_initial_recording( +def initrec_insert_initial_recording( data: dict[str, Any], ) -> None: logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung_unternehmen.insert().values(data) + stmt = db.grunderfassung.insert().values(data) with db.ENGINE.begin() as conn: conn.execute(stmt) -def update_initial_recording( +def initrec_update_initial_recording( id_: int, data: dict[str, Any], ) -> None: logger.debug("[Call backend] update_initial_recording") stmt = ( - db.grunderfassung_unternehmen.update() - .where(db.grunderfassung_unternehmen.c.erfassung_id == id_) - .values(data) + db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_).values(data) ) with db.ENGINE.begin() as conn: conn.execute(stmt) -def get_initial_recording( +def initrec_get_initial_recording( id_: int, ) -> dict[str, Any]: logger.debug("[Call backend] get_initial_recording") - stmt = db.grunderfassung_unternehmen.select().where( - db.grunderfassung_unternehmen.c.erfassung_id == id_ - ) + stmt = db.grunderfassung.select().where(db.grunderfassung.c.erfassung_id == id_) with db.ENGINE.begin() as conn: ret = conn.execute(stmt) @@ -192,18 +188,19 @@ def get_initial_recording( @dc.dataclass(slots=True) class FrontpageCompany: erfassung_id: int - ma_id: int + # ma_id: int name: str Metadaten_aktualisierung: datetime.datetime + is_company: bool -def get_company_list() -> list[FrontpageCompany]: +def front_get_company_list() -> list[FrontpageCompany]: logger.debug("[Call backend] get_company_list") stmt = sql.select( - db.grunderfassung_unternehmen.c.erfassung_id, - db.grunderfassung_unternehmen.c.Partnersuche__un_suche, - db.grunderfassung_unternehmen.c.Metadaten_aktualisierung, - ).order_by(db.grunderfassung_unternehmen.c.Metadaten_aktualisierung.desc()) + db.grunderfassung.c.erfassung_id, + db.grunderfassung.c.Partnersuche__un_suche, + db.grunderfassung.c.Metadaten_aktualisierung, + ).order_by(db.grunderfassung.c.Metadaten_aktualisierung.desc()) with db.ENGINE.connect() as conn: res = conn.execute(stmt) @@ -216,9 +213,21 @@ def get_company_list() -> list[FrontpageCompany]: datetime_akt = cast(datetime.datetime, entry[2]) datetime_akt = datetime_akt.astimezone() - comp_info = comp_search_get_info(ma_id) - name = comp_info["ma_unternehmensname"] + name: str = "PLATZHALTER INDIVIDUAL" + is_company: bool = False + if ma_id is not None: + comp_info = initrec_comp_search_get_info(ma_id) + name = comp_info["ma_unternehmensname"] + is_company = True - front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt)) + # front_page_companies.append(FrontpageCompany(erfassung_id, ma_id, name, datetime_akt)) + front_page_companies.append( + FrontpageCompany( + erfassung_id=erfassung_id, + name=name, + Metadaten_aktualisierung=datetime_akt, + is_company=is_company, + ) + ) return front_page_companies diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index f7f96a7..454f452 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -3,30 +3,19 @@ from __future__ import annotations import copy import dataclasses as dc import datetime -import enum -import json import pickle import re import sys import traceback -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 import 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, @@ -65,6 +54,13 @@ from PySide6.QtWidgets import ( import wce_crm.constants from wce_crm.backend import backend as be_init_rec +from wce_crm.data_models import COLUMN_SEP, FlatBaseModel, Grunderfassung +from wce_crm.form_defs import ( + INITREC_COMP, + INITREC_PERSON, + FormField, + FormFieldType, +) from wce_crm.logging import ( logger_auto_form, logger_get_data, @@ -103,12 +99,12 @@ QSS = """ """ 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( +# TODO check removal +def _save_pydantic_model_dict_db( model: FlatBaseModel, path: Path | None = None, ) -> None: @@ -123,7 +119,8 @@ def save_pydantic_model_dict_db( pickle.dump(export, f) -def load_pydantic_model_dict_db( +# TODO check removal +def _load_pydantic_model_dict_db( path: Path | None = None, ) -> dict[str, Any]: if path is None: @@ -164,197 +161,25 @@ def unmerge_dict_to_list( 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): +# TODO check removal +def _get_leafs(data): if isinstance(data, dict): for value in data.values(): - yield from get_leafs(value) + yield from _get_leafs(value) elif isinstance(data, (list, tuple, set)): for item in data: - yield from get_leafs(item) + yield from _get_leafs(item) else: yield data -def get_leaf_dicts(data): +# TODO check removal +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): + for inner_dict in _get_leaf_dicts(value): has_inner_dicts = True yield inner_dict @@ -363,7 +188,7 @@ def get_leaf_dicts(data): elif isinstance(data, (list, tuple, set)): for item in data: - yield from get_leaf_dicts(item) + yield from _get_leaf_dicts(item) def pprint_registry(widget_registry: WidgetRegistry) -> None: @@ -387,110 +212,6 @@ def pformat_registry(widget_registry: WidgetRegistry) -> str: return "\n".join(lines) -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]: ... @@ -1172,164 +893,6 @@ def validate_form_data( 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 - - -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, @@ -1479,7 +1042,7 @@ class Grunderfassung_SuchWidget(CustomWidget): 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() + search_choices = be_init_rec.initrec_comp_search_choices() for item, db_index in search_choices: self.company_search_input.addItem(item, db_index) self.company_search_input.setCurrentIndex(-1) @@ -1490,7 +1053,7 @@ class Grunderfassung_SuchWidget(CustomWidget): ) -> 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) + search_choices = be_init_rec.initrec_comp_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) @@ -1503,7 +1066,7 @@ class Grunderfassung_SuchWidget(CustomWidget): if ma_id is None or index == (-1): self._clear_company_fields() return - data = be_init_rec.comp_search_get_info( + data = be_init_rec.initrec_comp_search_get_info( ma_id=ma_id, ) self.fill_out_company(data) @@ -1518,7 +1081,7 @@ class Grunderfassung_SuchWidget(CustomWidget): self._clear_person_fields() return - data = be_init_rec.contact_person_search_get_info( + data = be_init_rec.initrec_comp_contact_person_search_get_info( an_id=an_id, ) self.fill_out_person(data) @@ -1783,7 +1346,7 @@ class AutoForm(QWidget): logger_auto_form.debug( "Loaded data dict:\n%s Passing to Pydantic...", pformat(loaded_data) ) - model = Grunderfassung_Unternehmen(**loaded_data) + model = Grunderfassung(**loaded_data) logger_auto_form.debug("Loaded to Pydantic.") logger_auto_form.debug("Convert to GUI structure...") form_data = model.to_gui() @@ -2492,9 +2055,9 @@ class NewEntrySelect_view(QWidget): btn_person.setFixedWidth(300) btn_person.setFixedHeight(40) btn_person.clicked.connect(lambda: self.person_requested.emit()) - btn_person.clicked.connect( - lambda: logger_gui.info("[Grunderfassung: Dummy Individualperson] Person gewählt") - ) + # btn_person.clicked.connect( + # lambda: logger_gui.info("[Grunderfassung: Dummy Individualperson] Person gewählt") + # ) layout.addWidget(back_btn) layout.addSpacing(15) @@ -2503,834 +2066,38 @@ class NewEntrySelect_view(QWidget): layout.addWidget(btn_person) +CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( + model=Grunderfassung, + data_insert=be_init_rec.initrec_insert_initial_recording, + data_update=be_init_rec.initrec_update_initial_recording, + data_get=be_init_rec.initrec_get_initial_recording, + ignored_keys=( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + "Metadaten_wiedereintrittsdatum", + ), + form_fields=INITREC_COMP, +) + +CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( + model=Grunderfassung, + data_insert=be_init_rec.initrec_insert_initial_recording, + data_update=be_init_rec.initrec_update_initial_recording, + data_get=be_init_rec.initrec_get_initial_recording, + ignored_keys=( + "Metadaten_erstellung", + "Metadaten_aktualisierung", + "Partnersuche", + ), + form_fields=INITREC_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 (vorausgefüllt 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"), - ), - 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", - ), - ], - ), - ], - ), -] - - -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, -) - - -class PageFormCompany(QWidget): +class Page_InitRecCompany(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) @@ -3459,6 +2226,135 @@ class PageFormCompany(QWidget): self.auto_form.reset_form() +class Page_InitRecPerson(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 Individualperson") + 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) + + # --- MAIN CONTENT --- + container = QWidget() + # SCROLL AREA + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1000) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # optional: remove frame to look more modern + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + vert_layout.addWidget(scroll_area) + + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + + # --- AUTO FORM LAYOUT --- + container_layout.addSpacing(20) + self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) + container_layout.addWidget(self.auto_form) + self.auto_form.save_clicked_form.connect(lambda: self.save_clicked_form.emit()) + + container_layout.addSpacing(15) + + # --- CUSTOM LOGIC --- + # ** fill 'Kontaktperson -> Namen Unternehmen' + # search_res = search_widgets_by_key(self.auto_form.widget_registry, "Partnersuche") + # assert len(search_res) == 1 + # search_widget = cast(Grunderfassung_SuchWidget, search_res[0]["widget"]) + # self.search_widget_trigger = cast( + # QLineEdit, search_widget.company_widgets["ma_unternehmensname"] + # ) + # text_widget_set = search_widgets_by_key( + # self.auto_form.widget_registry, f"Kontaktperson{COLUMN_SEP}KP_name_partner" + # ) + # assert len(text_widget_set) == 1 + # self.text_widget_set = cast(QLineEdit, text_widget_set[0]["widget"]) + # self.search_widget_trigger.textChanged.connect( + # self._custom_set_company_name_contact_person + # ) + + # ** 'Bundesland' only if 'Inland' selected in 'Stammdaten' + person_location = search_widgets_by_key( + self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}aufenthaltsort" + ) + assert len(person_location) == 1 + self.person_location = cast(QComboBox, person_location[0]["widget"]) + assert isinstance(self.person_location, QComboBox) + self.person_location.currentIndexChanged.connect(self._custom_county_selection) + + selection_county = search_widgets_by_key( + self.auto_form.widget_registry, f"Stammdaten{COLUMN_SEP}bundesland" + ) + assert len(selection_county) == 1 + self.selection_county = cast(QComboBox, selection_county[0]["widget"]) + assert isinstance(self.selection_county, QComboBox) + self.selection_county.setProperty("styleClass", "stempel") + self.selection_county.setEnabled(False) + + # def _custom_set_company_name_contact_person(self, value: str) -> None: + # self.text_widget_set.setText(str(value)) + + def _custom_county_selection(self, idx: int) -> None: + value = self.person_location.itemData(idx) + if value == "Inland": + self.selection_county.setEnabled(True) + self.selection_county.setProperty("styleClass", "") + self.selection_county.style().unpolish(self.selection_county) + self.selection_county.style().polish(self.selection_county) + self.selection_county.update() + else: + self.selection_county.setCurrentIndex(0) + self.selection_county.setEnabled(False) + self.selection_county.setProperty("styleClass", "stempel") + self.selection_county.style().unpolish(self.selection_county) + self.selection_county.style().polish(self.selection_county) + self.selection_county.update() + + def reset_form(self) -> None: + self.auto_form.reset_form() + + def clear_layout( layout: QLayout | None, ) -> None: @@ -3499,14 +2395,21 @@ class MainWindow(QMainWindow): # 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.new_entry_select.company_requested.connect(self.show_page_initrec_company) + self.new_entry_select.person_requested.connect(self.show_page_initrec_person) 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) + self.initrec_company = Page_InitRecCompany() + self.initrec_company.back_main_requested.connect(self.show_main_page) + self.initrec_company.back_requested.connect(self.show_new_entry_select) + self.initrec_company.save_clicked_form.connect(self.update_grid) + self.stack.addWidget(self.initrec_company) + # SITE: 'Grunderfassung Person' + self.initrec_person = Page_InitRecPerson() + self.initrec_person.back_main_requested.connect(self.show_main_page) + self.initrec_person.back_requested.connect(self.show_new_entry_select) + self.initrec_person.save_clicked_form.connect(self.update_grid) + self.stack.addWidget(self.initrec_person) def setup_main_page(self): # QMainWindow defines frame --> container widget needed for the middle @@ -3602,12 +2505,15 @@ class MainWindow(QMainWindow): def update_grid(self) -> None: clear_layout(self.grid) - data = be_init_rec.get_company_list() + data = be_init_rec.front_get_company_list() for entry in data: self.add_row_to_grid(entry) - def add_row_to_grid(self, entry: be_init_rec.FrontpageCompany): + def add_row_to_grid( + self, + entry: be_init_rec.FrontpageCompany, + ): row = self.current_row # NAME @@ -3628,7 +2534,8 @@ class MainWindow(QMainWindow): # else: # empty_box = QFrame() # empty_box.setStyleSheet( - # "QFrame { background-color: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 8px; }" + # "QFrame { background-color: #f8fafc; border: 2px + # dashed #e2e8f0; border-radius: 8px; }" # ) # self.grid.addWidget(empty_box, row, 2) @@ -3641,8 +2548,13 @@ class MainWindow(QMainWindow): self, data: be_init_rec.FrontpageCompany, ): - self.company_recording_page.auto_form.load_data(data.erfassung_id) - self.stack.setCurrentWidget(self.company_recording_page) + if data.is_company: + self.initrec_company.auto_form.load_data(data.erfassung_id) + self.stack.setCurrentWidget(self.initrec_company) + else: + self.initrec_person.auto_form.load_data(data.erfassung_id) + self.stack.setCurrentWidget(self.initrec_person) + # 1. Daten an die Detail-Seite übergeben # self.detail_page.update_content(data) # 2. Auf die Detail-Seite umblättern @@ -3655,9 +2567,13 @@ class MainWindow(QMainWindow): 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) + def show_page_initrec_company(self): + self.initrec_company.reset_form() + self.stack.setCurrentWidget(self.initrec_company) + + def show_page_initrec_person(self): + self.initrec_person.reset_form() + self.stack.setCurrentWidget(self.initrec_person) # --- MENÜ LOGIK --- def create_menu(self): -- 2.34.1 From f24c4f0ff70ed403b84c844cd1f9a12b3ba1e21b Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 16:19:44 +0200 Subject: [PATCH 04/12] robust `.env` loading --- src/wce_crm/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wce_crm/__init__.py b/src/wce_crm/__init__.py index c6fce6b..8ec350d 100644 --- a/src/wce_crm/__init__.py +++ b/src/wce_crm/__init__.py @@ -1,5 +1,6 @@ import os import sys +from pathlib import Path import dotenv @@ -7,4 +8,8 @@ if sys.stdout is None or sys.stderr is None: sys.stdout = open(os.devnull, "w") sys.stderr = open(os.devnull, "w") -dotenv.load_dotenv() +deploy_env_pth = Path(sys.executable).parent / ".env" +if deploy_env_pth.exists(): + dotenv.load_dotenv(dotenv_path=deploy_env_pth) +else: + dotenv.load_dotenv() -- 2.34.1 From decfdbffd17da5e5d58a37eda4449a4756d22692 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 16:54:54 +0200 Subject: [PATCH 05/12] small refactoring with more precise declarations --- src/wce_crm/backend/backend.py | 48 ++++++++++++----- src/wce_crm/gui.py | 94 ++++++++++++++++++++++++---------- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 8ad2161..fff4103 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -2,7 +2,7 @@ from __future__ import annotations import dataclasses as dc import datetime -from typing import Any, TypedDict, cast +from typing import Any, TypeAlias, TypedDict, cast import polars as pl import sqlalchemy as sql @@ -10,6 +10,8 @@ import sqlalchemy as sql from wce_crm import db from wce_crm.logging import logger_back as logger +InitRecId: TypeAlias = int + class CompanyInfo(TypedDict): ma_id: str @@ -151,43 +153,63 @@ def initrec_comp_contact_person_search_get_info( def initrec_insert_initial_recording( data: dict[str, Any], -) -> None: +) -> InitRecId: logger.debug("[Call backend] insert_initial_recording") - stmt = db.grunderfassung.insert().values(data) + stmt = db.grunderfassung.insert() with db.ENGINE.begin() as conn: - conn.execute(stmt) + ret = conn.execute(stmt, data) + + if ret.rowcount == 0: + raise IOError("Entry was not inserted correctly") + + prim_keys = ret.inserted_primary_key + assert prim_keys + + return prim_keys[0] def initrec_update_initial_recording( - id_: int, + id_: InitRecId, data: dict[str, Any], ) -> None: logger.debug("[Call backend] update_initial_recording") - stmt = ( - db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_).values(data) - ) + stmt = db.grunderfassung.update().where(db.grunderfassung.c.erfassung_id == id_) with db.ENGINE.begin() as conn: - conn.execute(stmt) + conn.execute(stmt, data) def initrec_get_initial_recording( - id_: int, + id_: InitRecId, ) -> dict[str, Any]: logger.debug("[Call backend] get_initial_recording") stmt = db.grunderfassung.select().where(db.grunderfassung.c.erfassung_id == id_) with db.ENGINE.begin() as conn: ret = conn.execute(stmt) - row = ret.fetchone() - if row is None: + if ret.rowcount == 0: raise KeyError(f"Database ID {id_} not found") + row = ret.fetchone() + assert row, "row was not obtained" + return row._asdict() +def initrec_delete_initial_recording( + id_: InitRecId, +) -> None: + logger.debug("[Call backend] delete_initial_recording") + stmt = db.grunderfassung.delete().where(db.grunderfassung.c.erfassung_id == id_) + with db.ENGINE.begin() as conn: + ret = conn.execute(stmt) + + if ret.rowcount == 0: + raise KeyError(f"Database ID {id_} not found for deletion") + + @dc.dataclass(slots=True) class FrontpageCompany: - erfassung_id: int + erfassung_id: InitRecId # ma_id: int name: str Metadaten_aktualisierung: datetime.datetime diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 454f452..b5ebef9 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -53,7 +53,7 @@ from PySide6.QtWidgets import ( ) import wce_crm.constants -from wce_crm.backend import backend as be_init_rec +from wce_crm.backend import backend from wce_crm.data_models import COLUMN_SEP, FlatBaseModel, Grunderfassung from wce_crm.form_defs import ( INITREC_COMP, @@ -226,7 +226,7 @@ class AutoFormInsert(Protocol): def __call__( self, data: dict[str, Any], - ) -> None: ... + ) -> backend.InitRecId: ... class AutoFormUpdate(Protocol): @@ -244,12 +244,20 @@ class AutoFormGet(Protocol): ) -> dict[str, Any]: ... +class AutoFormDelete(Protocol): + def __call__( + self, + id_: int, + ) -> None: ... + + @dc.dataclass(slots=True) class AutoFormConfig: model: type[FlatBaseModel] data_insert: AutoFormInsert data_update: AutoFormUpdate data_get: AutoFormGet + data_delete: AutoFormDelete form_fields: Sequence[FormField] ignored_keys: Iterable[str] = tuple() add_buttons: bool = True @@ -1001,7 +1009,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_company( self, - data: be_init_rec.CompanyInfo, + data: backend.CompanyInfo, ) -> None: for key, widget in self.company_widgets.items(): if key not in data: @@ -1015,7 +1023,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def fill_out_person( self, - data: be_init_rec.ContactPersonInfo, + data: backend.ContactPersonInfo, ) -> None: for key, widget in self.person_widgets.items(): if key not in data: @@ -1042,7 +1050,7 @@ class Grunderfassung_SuchWidget(CustomWidget): def update_company_data(self) -> None: self.company_search_input.clear() self.company_search_input.addItem(DROPDOWN_DEFAULT, None) - search_choices = be_init_rec.initrec_comp_search_choices() + search_choices = backend.initrec_comp_search_choices() for item, db_index in search_choices: self.company_search_input.addItem(item, db_index) self.company_search_input.setCurrentIndex(-1) @@ -1053,7 +1061,7 @@ class Grunderfassung_SuchWidget(CustomWidget): ) -> None: self.person_search_input.clear() self.person_search_input.addItem(DROPDOWN_DEFAULT, None) - search_choices = be_init_rec.initrec_comp_contact_person_search_choices(ma_id, True) + search_choices = backend.initrec_comp_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) @@ -1066,7 +1074,7 @@ class Grunderfassung_SuchWidget(CustomWidget): if ma_id is None or index == (-1): self._clear_company_fields() return - data = be_init_rec.initrec_comp_search_get_info( + data = backend.initrec_comp_search_get_info( ma_id=ma_id, ) self.fill_out_company(data) @@ -1081,7 +1089,7 @@ class Grunderfassung_SuchWidget(CustomWidget): self._clear_person_fields() return - data = be_init_rec.initrec_comp_contact_person_search_get_info( + data = backend.initrec_comp_contact_person_search_get_info( an_id=an_id, ) self.fill_out_person(data) @@ -1159,7 +1167,7 @@ def search_widgets_by_key( 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) + update_triggered = Signal() # formular saved (data changed for front page) def __init__( self, @@ -1273,6 +1281,7 @@ class AutoForm(QWidget): if self.add_buttons: self.layout_btn = QHBoxLayout() self.main_layout.addLayout(self.layout_btn) + # save self.save_btn_txt_enabled = "Speichern (Strg + S)" self.save_btn_txt_disabled = "Wird gespeichert..." self.save_btn = QPushButton(self.save_btn_txt_enabled) @@ -1283,6 +1292,7 @@ class AutoForm(QWidget): ) self.save_btn.clicked.connect(self.save_data) self.layout_btn.addWidget(self.save_btn) + # reset self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") self.reset_btn.setShortcut("Ctrl+Z") self.reset_btn.setFixedHeight(50) @@ -1291,6 +1301,15 @@ class AutoForm(QWidget): ) self.reset_btn.clicked.connect(self.reset_form) self.layout_btn.addWidget(self.reset_btn) + # delete + self.delete_btn = QPushButton("Eintrag löschen (Strg + L)") + self.delete_btn.setShortcut("Ctrl+L") + self.delete_btn.setFixedHeight(50) + self.delete_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.delete_btn.clicked.connect(self.delete_data) + self.layout_btn.addWidget(self.delete_btn) self.current_id: int = -1 @@ -1322,6 +1341,18 @@ class AutoForm(QWidget): QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) self.save_btn.setText(self.save_btn_txt_enabled) + def _activation_delete(self) -> None: + if self.current_id > -1: + self.delete_btn.setEnabled(True) + else: + self.delete_btn.setEnabled(False) + + def delete_data(self) -> None: + assert self.current_id > -1, "deletion initialised despite no index set" + self.cfg.data_delete(self.current_id) + self.update_triggered.emit() + self.reset_form() + def load_data( self, lookup_id: int | None = None, @@ -1354,6 +1385,7 @@ class AutoForm(QWidget): logger_auto_form.debug("Form data:\n%s", pformat(form_data)) self.set_form_data(form_data) self.current_id = lookup_id + self._activation_delete() def save_data(self) -> None: self._disable_save() @@ -1417,13 +1449,16 @@ class AutoForm(QWidget): if self.current_id < 0: logger_auto_form.debug("Insert triggered") - self.cfg.data_insert(db_data) + init_rec_id = self.cfg.data_insert(db_data) + assert isinstance(init_rec_id, int) + self.current_id = init_rec_id 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.update_triggered.emit() + self._activation_delete() # self.reset_form() # TODO check if this behaviour is expected finally: # always re-enable save, even if error occurred @@ -1435,6 +1470,7 @@ class AutoForm(QWidget): def reset_form(self) -> None: reset_form(self.widget_registry) self.current_id = -1 + self._activation_delete() def get_form_data(self) -> dict[str, Any]: form_data = get_form_data(self.widget_registry) @@ -1977,12 +2013,12 @@ class NoScrollFilter(QObject): class ClickableCell(QFrame): """cell in the table on the startup screen""" - clicked = Signal(be_init_rec.FrontpageCompany) + clicked = Signal(backend.FrontpageCompany) def __init__( self, text: str, - data_record: be_init_rec.FrontpageCompany, + data_record: backend.FrontpageCompany, ): super().__init__() self.data_record = data_record @@ -2068,9 +2104,10 @@ class NewEntrySelect_view(QWidget): CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung, - data_insert=be_init_rec.initrec_insert_initial_recording, - data_update=be_init_rec.initrec_update_initial_recording, - data_get=be_init_rec.initrec_get_initial_recording, + data_insert=backend.initrec_insert_initial_recording, + data_update=backend.initrec_update_initial_recording, + data_get=backend.initrec_get_initial_recording, + data_delete=backend.initrec_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", @@ -2081,9 +2118,10 @@ CONFIG_GRUNDERFASSUNG_UNTERNEHMEN: Final[AutoFormConfig] = AutoFormConfig( CONFIG_GRUNDERFASSUNG_PERSONEN: Final[AutoFormConfig] = AutoFormConfig( model=Grunderfassung, - data_insert=be_init_rec.initrec_insert_initial_recording, - data_update=be_init_rec.initrec_update_initial_recording, - data_get=be_init_rec.initrec_get_initial_recording, + data_insert=backend.initrec_insert_initial_recording, + data_update=backend.initrec_update_initial_recording, + data_get=backend.initrec_get_initial_recording, + data_delete=backend.initrec_delete_initial_recording, ignored_keys=( "Metadaten_erstellung", "Metadaten_aktualisierung", @@ -2100,7 +2138,7 @@ CUSTOM_WIDGETS: Final[dict[str, type[CustomWidget]]] = { class Page_InitRecCompany(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) + update_triggered = Signal() # form saved (data changed for front page) def __init__(self): super().__init__() @@ -2164,7 +2202,7 @@ class Page_InitRecCompany(QWidget): container_layout.addSpacing(20) 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()) + self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) container_layout.addSpacing(15) @@ -2229,7 +2267,7 @@ class Page_InitRecCompany(QWidget): class Page_InitRecPerson(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) + update_triggered = Signal() # form saved (data changed for front page) def __init__(self): super().__init__() @@ -2293,7 +2331,7 @@ class Page_InitRecPerson(QWidget): container_layout.addSpacing(20) self.auto_form = AutoForm(cfg=CONFIG_GRUNDERFASSUNG_PERSONEN) container_layout.addWidget(self.auto_form) - self.auto_form.save_clicked_form.connect(lambda: self.save_clicked_form.emit()) + self.auto_form.update_triggered.connect(lambda: self.update_triggered.emit()) container_layout.addSpacing(15) @@ -2402,13 +2440,13 @@ class MainWindow(QMainWindow): self.initrec_company = Page_InitRecCompany() self.initrec_company.back_main_requested.connect(self.show_main_page) self.initrec_company.back_requested.connect(self.show_new_entry_select) - self.initrec_company.save_clicked_form.connect(self.update_grid) + self.initrec_company.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_company) # SITE: 'Grunderfassung Person' self.initrec_person = Page_InitRecPerson() self.initrec_person.back_main_requested.connect(self.show_main_page) self.initrec_person.back_requested.connect(self.show_new_entry_select) - self.initrec_person.save_clicked_form.connect(self.update_grid) + self.initrec_person.update_triggered.connect(self.update_grid) self.stack.addWidget(self.initrec_person) def setup_main_page(self): @@ -2505,14 +2543,14 @@ class MainWindow(QMainWindow): def update_grid(self) -> None: clear_layout(self.grid) - data = be_init_rec.front_get_company_list() + data = backend.front_get_company_list() for entry in data: self.add_row_to_grid(entry) def add_row_to_grid( self, - entry: be_init_rec.FrontpageCompany, + entry: backend.FrontpageCompany, ): row = self.current_row @@ -2546,7 +2584,7 @@ class MainWindow(QMainWindow): def goto_initial_recording( self, - data: be_init_rec.FrontpageCompany, + data: backend.FrontpageCompany, ): if data.is_company: self.initrec_company.auto_form.load_data(data.erfassung_id) -- 2.34.1 From 389fe2e1599ba69d5eeb34edfd173854ff9c0f5f Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 16:55:24 +0200 Subject: [PATCH 06/12] add database migration tooling with `alembic` --- alembic.ini | 148 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 80 ++++++++++ alembic/script.py.mako | 28 ++++ ...added_new_column_and_renamed_table_for_.py | 91 +++++++++++ deployment/.env | 3 +- pdm.lock | 23 ++- pyproject.toml | 2 +- scripts/alembic_revision.ps1 | 5 + src/wce_crm/README.md | 1 + src/wce_crm/constants.py | 1 + src/wce_crm/db.py | 15 +- src/wce_crm/db_migration.py | 36 +++++ 13 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py create mode 100644 scripts/alembic_revision.ps1 create mode 100644 src/wce_crm/db_migration.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c265a4f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,148 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///data/db/wce_grunderfassung.db + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..f72a084 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,80 @@ +from logging.config import fileConfig +from pathlib import Path + +import dotenv +from alembic import context +from sqlalchemy import engine_from_config, pool + +from wce_crm import constants, db + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = db.MD_MAIN + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. +config.set_main_option("sqlalchemy.url", f"sqlite:///{constants.Config.DB_PATH_MAIN}") + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py new file mode 100644 index 0000000..4ec9390 --- /dev/null +++ b/alembic/versions/5f2af5179c47_added_new_column_and_renamed_table_for_.py @@ -0,0 +1,91 @@ +"""added new column and renamed table for initial recording + +Revision ID: 5f2af5179c47 +Revises: +Create Date: 2026-06-17 16:06:46.421562 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5f2af5179c47" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.rename_table("grunderfassung_unternehmen", "grunderfassung") + op.add_column( + "grunderfassung", + sa.Column("Metadaten_wiedereintrittsdatum", sa.Date, nullable=True, default=None), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "grunderfassung_unternehmen", + sa.Column("erfassung_id", sa.INTEGER(), nullable=False), + sa.Column("Metadaten_erstellung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_aktualisierung", sa.DATETIME(), nullable=True), + sa.Column("Metadaten_nutzer", sa.VARCHAR(length=20), nullable=True), + sa.Column("Arbeitserfahrung", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_fallnummer", sa.TEXT(), nullable=True), + sa.Column("Grunderfassung_notiz", sa.TEXT(), nullable=True), + sa.Column("HoehereBildung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_adresse", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_email", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_funktion_beziehung", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_name_partner", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_titel", sa.TEXT(), nullable=True), + sa.Column("Kontaktperson__KP_vorname", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__kanal_aufmerksamkeit", sa.TEXT(), nullable=True), + sa.Column("Partnersuche__person_suche", sa.INTEGER(), nullable=True), + sa.Column("Partnersuche__un_suche", sa.INTEGER(), nullable=True), + sa.Column("Projektrelevanz__relevanz", sa.TEXT(), nullable=True), + sa.Column("Projektrelevanz__foerderperiode", sa.TEXT(), nullable=True), + sa.Column("Schulbildung", sa.TEXT(), nullable=True), + sa.Column("Sprachkenntnisse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__PLZ", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anrede_anschrift", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__alter", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__anzahl_kinder__anzahl", sa.INTEGER(), nullable=True), + sa.Column("Stammdaten__aufenthaltsort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__bundesland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__land", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__email", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__familienstand", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__festnetznummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__geburtsdatum", sa.DATE(), nullable=True), + sa.Column("Stammdaten__hausnummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__herkunftsland", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__mobilfunknummer", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__name", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__ort", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__rueckkehrer", sa.BOOLEAN(), nullable=True), + sa.Column("Stammdaten__staatsangehoerigkeit", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__strasse", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__titel", sa.TEXT(), nullable=True), + sa.Column("Stammdaten__vorname", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_arbeitsstatus", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_aufenthaltstitel", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_deutsch_sprache", sa.TEXT(), nullable=True), + sa.Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sa.DATE(), nullable=True), + sa.Column("WeitereInfos__WI_meldung_institution", sa.TEXT(), nullable=True), + sa.PrimaryKeyConstraint("erfassung_id"), + ) + op.drop_table("grunderfassung") + # ### end Alembic commands ### diff --git a/deployment/.env b/deployment/.env index 4e75a4b..e2b29a0 100644 --- a/deployment/.env +++ b/deployment/.env @@ -1,4 +1,5 @@ DOPT_STOP_FOLDER_NAME=python DOPT_DB_CRM=data/wce_crm.db DOPT_DB_MAIN=data/wce_grunderfassung.db -DOPT_PATH_LOGGING=data/logs \ No newline at end of file +DOPT_PATH_LOGGING=data/logs +DOPT_ALEMBIC_BASE=python/alembic \ No newline at end of file diff --git a/pdm.lock b/pdm.lock index cd1f265..a9df463 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "lint", "nb", "tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:25812ee6ba42033e1341c1799716828d8582092826c1d0889ea775a0f5c548a2" +content_hash = "sha256:bde2f66706034b9c34c6794bf192b44db6e116a287938d88c90c751a2666bdf7" [[metadata.targets]] requires_python = ">=3.11,<3.14" @@ -152,6 +152,23 @@ files = [ {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] +[[package]] +name = "alembic" +version = "1.18.4" +requires_python = ">=3.10" +summary = "A database migration tool for SQLAlchemy." +groups = ["default"] +dependencies = [ + "Mako", + "SQLAlchemy>=1.4.23", + "tomli; python_version < \"3.11\"", + "typing-extensions>=4.12", +] +files = [ + {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, + {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -1845,7 +1862,7 @@ name = "mako" version = "1.3.11" requires_python = ">=3.8" summary = "A super-fast templating language that borrows the best ideas from the existing templating languages." -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "MarkupSafe>=0.9.2", ] @@ -1895,7 +1912,7 @@ name = "markupsafe" version = "3.0.3" requires_python = ">=3.9" summary = "Safely add untrusted strings to HTML/XML markup." -groups = ["dev", "nb"] +groups = ["default", "dev", "nb"] files = [ {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, diff --git a/pyproject.toml b/pyproject.toml index 5f1caf7..694d6f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "GUI for CRM of NAFKA project with WCE" authors = [ {name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, ] -dependencies = ["pyside6>=6.11.0", "sqlalchemy[asyncio]>=2.0.50", "polars>=1.40.1", "dopt-basics>=0.2.6", "pydantic[email]>=2.13.4", "babel>=2.18.0", "python-dotenv>=1.2.2"] +dependencies = ["pyside6>=6.11.0", "sqlalchemy[asyncio]>=2.0.50", "polars>=1.40.1", "dopt-basics>=0.2.6", "pydantic[email]>=2.13.4", "babel>=2.18.0", "python-dotenv>=1.2.2", "alembic>=1.18.4"] requires-python = "<3.14,>=3.11" readme = "README.md" license = {text = "LicenseRef-Proprietary"} diff --git a/scripts/alembic_revision.ps1 b/scripts/alembic_revision.ps1 new file mode 100644 index 0000000..ff49f59 --- /dev/null +++ b/scripts/alembic_revision.ps1 @@ -0,0 +1,5 @@ +param( + [string]$Message +) + +pdm run alembic revision --autogenerate -m $Message diff --git a/src/wce_crm/README.md b/src/wce_crm/README.md index 4ea9ef6..625eb85 100644 --- a/src/wce_crm/README.md +++ b/src/wce_crm/README.md @@ -5,3 +5,4 @@ - DOPT_DB_CRM: path to CRM database, relative to base path - DOPT_DB_MAIN: path to main database, relative to base path - DOPT_PATH_LOGGING: path to logging folder, relative to base path +- DOPT_ALEMBIC_BASE: path to all relevant alembic file diff --git a/src/wce_crm/constants.py b/src/wce_crm/constants.py index e555c32..70ac77d 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -24,3 +24,4 @@ class Config: ) PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log") LOG_FILENAME: str = "dopt.log" + ALEMBIC_PATH: Path = BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic") diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index fb380c6..f5d2db2 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -13,6 +13,7 @@ from wce_crm import constants from wce_crm import types as t +# // declarations class SafeDateTime(TypeDecorator): """Cleans non-standard ISO strings before parsing.""" @@ -57,8 +58,8 @@ class UTCDateTime(TypeDecorator): return value -md_crm = sql.MetaData() -md_main = sql.MetaData() +MD_CRM = sql.MetaData() +MD_MAIN = sql.MetaData() ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") # ---------- OLD "Kontaktliste" ---------- @@ -141,7 +142,7 @@ ENGINE = sql.create_engine(f"sqlite:///{constants.Config.DB_PATH_MAIN}") ext_crm_master: sql.Table = Table( "Master", - md_crm, + MD_CRM, Column("ma_id", sql.Integer, nullable=False, unique=True), Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), Column("ma_unternehmensname", sql.Text, nullable=True), @@ -218,7 +219,7 @@ DF_CRM_MASTER = get_ext_crm_master(constants.Config.DB_PATH_CRM) ext_crm_nutzer: sql.Table = Table( "Nutzer", - md_crm, + MD_CRM, Column("wce_id", sql.Integer, nullable=False, unique=True), Column("wce_name", sql.Text, nullable=True), Column("wce_vorname", sql.Text, nullable=True), @@ -246,7 +247,7 @@ ext_crm_nutzer_schema: t.PolarsSchema = { ext_crm_contact_person: sql.Table = Table( "Ansprechpartner", - md_crm, + MD_CRM, Column("an_id", sql.Integer, nullable=False, unique=True), Column("ma_id", sql.ForeignKey("Master.ma_id")), Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), @@ -325,7 +326,7 @@ DF_CONTACT_PERSON = get_ext_crm_contact_person(constants.Config.DB_PATH_CRM) grunderfassung: sql.Table = Table( "grunderfassung", - md_main, + MD_MAIN, Column( "erfassung_id", sql.Integer, @@ -398,4 +399,4 @@ grunderfassung: sql.Table = Table( Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), ) -md_main.create_all(ENGINE) +MD_MAIN.create_all(ENGINE) diff --git a/src/wce_crm/db_migration.py b/src/wce_crm/db_migration.py new file mode 100644 index 0000000..55837bf --- /dev/null +++ b/src/wce_crm/db_migration.py @@ -0,0 +1,36 @@ +from alembic import command +from alembic.config import Config + +from wce_crm import constants +from wce_crm.logging import logger_base as logger + + +def check_and_run_migrations() -> None: + logger.info("[DB migration - Alembic] Start...") + alembic_base_folder = constants.Config.ALEMBIC_PATH + alembic_ini_file = alembic_base_folder / "alembic.ini" + alembic_working_dir = alembic_base_folder / "alembic" + + assert alembic_ini_file.exists(), "Alembic INI file not found" + assert alembic_working_dir.exists(), "Alembic working dir not found" + assert alembic_working_dir.is_dir(), "Alembic working dir is not a directory" + + logger.info("[DB migration - Alembic] Initialise config...") + alembic_cfg = Config(alembic_ini_file) + alembic_cfg.set_main_option("script_location", str(alembic_working_dir)) + + logger.info("[DB migration - Alembic] Run...") + try: + command.upgrade(alembic_cfg, "head") + logger.info("[DB migration - Alembic] Database up-to-date.") + except Exception as err: + logger.info( + "[DB migration - Alembic] An error occurred during the migration:\n%s", + err, + stack_info=True, + ) + + +if __name__ == "__main__": + # start migration process before app start + check_and_run_migrations() -- 2.34.1 From 696357138a78c9d2f701945df8bedda853482bfd Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:30:52 +0200 Subject: [PATCH 07/12] adapt logging behaviour --- src/wce_crm/logging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wce_crm/logging.py b/src/wce_crm/logging.py index c8afd46..a9a36ca 100644 --- a/src/wce_crm/logging.py +++ b/src/wce_crm/logging.py @@ -25,6 +25,7 @@ LOGGING_CFG: LoggingConfig = LoggingConfig( setup_logging(LOGGING_CFG) logger_base = BASE_LOGGER.getChild("wce_crm") +logger_base.setLevel(logging.DEBUG) # ** GUI logger_gui = logger_base.getChild("gui") -- 2.34.1 From 4364d8c0f95612aee9e576376192419a031d527f Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:31:09 +0200 Subject: [PATCH 08/12] remove unused imports --- alembic/env.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/alembic/env.py b/alembic/env.py index f72a084..02f88a9 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,7 +1,5 @@ from logging.config import fileConfig -from pathlib import Path -import dotenv from alembic import context from sqlalchemy import engine_from_config, pool -- 2.34.1 From 5ce911f8cd27baae415308d4c4b297c993c5d139 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:31:32 +0200 Subject: [PATCH 09/12] fix for `alembic` pipeline --- scripts/build.ps1 | 39 +++++++++++++++++++++++++++++++++++++++ src/wce_crm/db.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 844618f..55424fb 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -8,6 +8,28 @@ $ENV_PATH = 'B:\deployments\WCE-NAFKA\dopt_nafka_wce-crm' $PY_PATH = Join-Path -Path $ENV_PATH -ChildPath 'python' $SRC_PATH = (Get-Location).Path +function create_folder { + param ( + [string]$base_path, + [string]$folder_name, + [switch]$recreate + ) + $target_path = Join-Path -Path $base_path -ChildPath $folder_name + + $target_path_exists = Test-Path -Path $target_path + if (-not $target_path_exists){ + Write-Output "[PWSH] Folder >$folder_name< not existing. Create..." + New-Item -Path $target_path -ItemType Directory + } + elseif ($target_path_exists -and $recreate){ + Write-Output "[PWSH] Folder >$folder_name< exists, but should be recreated..." + Remove-Item -Path $target_path -Recurse -Force + New-Item -Path $target_path -ItemType Directory + } + else { + Write-Output "Folder >$folder_name< already exists." + } +} Write-Output "Build Pipeline for d-opt WCE/NAFKA project" @@ -88,6 +110,23 @@ if ($? -eq $false){ } Write-Output "Copied database files successfully" +# copy alembic files +Write-Output "Copying alembic files..." +$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic' +$dest_file = Join-Path -Path $PY_PATH -ChildPath 'alembic' +create_folder -base_path $PY_PATH -folder_name 'alembic' +Copy-Item -Path $copy_file -Destination $dest_file -Force -Recurse +if ($? -eq $false){ + Write-Output "[PWSH] Exiting script because there were errors while copying the alembic folder" + Exit +} +$copy_file = Join-Path -Path $SRC_PATH -ChildPath 'alembic.ini' +Copy-Item -Path $copy_file -Destination $dest_file -Force +if ($? -eq $false){ + Write-Output "[PWSH] Exiting script because there were errors while copying the alembic INI file" + Exit +} + # copy .env file Write-Output "Copying ENV file..." $env_file = Join-Path -Path $SRC_PATH -ChildPath 'deployment/.env' diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index f5d2db2..5111636 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -399,4 +399,4 @@ grunderfassung: sql.Table = Table( Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), ) -MD_MAIN.create_all(ENGINE) +# MD_MAIN.create_all(ENGINE) -- 2.34.1 From fdf44454a001b56c17cc614a46429a7cd243d064 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:31:38 +0200 Subject: [PATCH 10/12] bump version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 694d6f0..f47c991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wce-crm" -version = "0.1.1dev7" +version = "0.1.1dev10" description = "GUI for CRM of NAFKA project with WCE" authors = [ {name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, @@ -71,7 +71,7 @@ directory = "reports/coverage" [tool.bumpversion] -current_version = "0.1.1dev7" +current_version = "0.1.1dev10" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. -- 2.34.1 From abb4c67b6767b9f810a1f103074507bac87f2b5c Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:34:27 +0200 Subject: [PATCH 11/12] doc version history --- docs/Versionshistorie.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/Versionshistorie.md b/docs/Versionshistorie.md index dfe1622..d2bda5c 100644 --- a/docs/Versionshistorie.md +++ b/docs/Versionshistorie.md @@ -1,5 +1,10 @@ # Versionshistorie (Changelog) +## 17.06.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260617) + +- Grunderfassung für Individualpersonen hinzugefügt +- Löschen von Einträgen möglich (Option/Schaltfläche neben dem Speichern und Zurücksetzen: Tastenkombination ``Strg + L``) + ## 29.05.2026 (Version: v0.1.1dev) (Tag: ExtTest-20260529) - initiale Version für erste Feedback-Schleife -- 2.34.1 From 27934497d5dac096ab8f69c3a42115f88bc410b4 Mon Sep 17 00:00:00 2001 From: foefl Date: Wed, 17 Jun 2026 17:40:07 +0200 Subject: [PATCH 12/12] remove metadata creation --- src/wce_crm/db.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py index 5111636..2c2a2a2 100644 --- a/src/wce_crm/db.py +++ b/src/wce_crm/db.py @@ -398,5 +398,3 @@ grunderfassung: sql.Table = Table( Column("WeitereInfos__WI_gueltigkeit_aufenthaltstitel", sql.Date, nullable=True), Column("WeitereInfos__WI_meldung_institution", sql.Text, nullable=True), ) - -# MD_MAIN.create_all(ENGINE) -- 2.34.1