from __future__ import annotations import copy import dataclasses as dc import datetime import json from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar from pydantic import ( AwareDatetime, BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator, ) from pydantic_core import ErrorDetails from wce_crm.form_defs import FormField from wce_crm.types import ConsId, ConsultingType, RecId, UserId if TYPE_CHECKING: from wce_crm.gui import Page_Consulting_ConsultingSession # noqa: F401 ValidAge = Annotated[int, Field(ge=0, le=99)] COLUMN_SEP: Final[str] = "__" M = TypeVar("M", bound="Module") S = TypeVar("S", bound="PageState") # // validation translation GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = { "missing": "Dieses Feld ist ein Pflichtfeld.", "int_parsing": "Bitte eine gültige Ganzzahl eingeben.", "float_parsing": "Bitte eine gültige Dezimalzahl eingeben.", "string_too_short": "Der Text ist zu kurz. Er muss mindestens {min_length} Zeichen lang sein.", "string_too_long": "Der Text ist zu lang. Er darf maximal {max_length} Zeichen lang sein.", "greater_than": "Der Wert muss größer als {gt} sein.", } def translate_pydantic_errors( errors: list[ErrorDetails], ) -> list[ErrorDetails]: translated_errors: list[ErrorDetails] = [] for error in errors: err_type = error.get("type", None) if err_type is None or err_type not in GERMAN_ERROR_MESSAGES: error["msg"] = "[fehlende Übersetzung, nur Englisch]: " + error["msg"] translated_errors.append(error) continue msg_template = GERMAN_ERROR_MESSAGES[err_type] ctx = error.get("ctx", {}) try: error["msg"] = msg_template.format(**ctx) except KeyError: error["msg"] = msg_template translated_errors.append(error) return translated_errors 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 # // GUI states and types def set_page_state( target: S, source: S, deep: bool = False, ) -> None: """ transfer all field values generically from 'source' to 'target' """ if not (dc.is_dataclass(target) and dc.is_dataclass(source)): raise TypeError("Both source and atrget must be dataclasses") if type(target) is not type(source): raise TypeError("Both source and target must have the same type") for field in dc.fields(source): if field.name == "child_modules": continue new_value = getattr(source, field.name) if deep: new_value = copy.deepcopy(new_value) setattr(target, field.name, new_value) class WrapperModule(Protocol): def _sync_state_to_GUI(self, *args, **kwargs) -> None: """build states for all child modules and use their `load_state` method to initialise them with the new data """ ... def _sync_GUI_to_state(self, *args, **kwargs) -> None: """call `get_state` method on all child modules and assign respective properties to state of parent module """ ... def load_state(self, new_state: Any) -> None: ... def get_state(self) -> Any: """should always be a simple call to `_sync_GUI_to_state` and then returning the module's state Returns ------- Any state of the respective module """ ... def lock(self) -> None: ... def unlock(self) -> None: ... class Module(WrapperModule, Protocol): def validate(self) -> list[str]: """should be a list of field names which could not be validated successfully, propagates through: parent components must call this on all child modules and extend their own error list with the return value Returns ------- list[str] list of error fields (form field label) """ ... def save_data(self) -> None: ... class AutoFormInsert(Protocol): def __call__( self, data: dict[str, Any], ) -> RecId: ... class AutoFormUpdate(Protocol): def __call__( self, id_: int, data: dict[str, Any], ) -> None: ... class AutoFormGet(Protocol): def __call__( self, id_: int, ) -> dict[str, Any]: ... class AutoFormDelete(Protocol): def __call__( self, id_: int, ) -> None: ... class AutoFormToDb(Protocol): def __call__( self, auto_form_data: Initrec, ) -> Initrec: ... class AutoFormFromDb(Protocol): def __call__( self, id_: int, ) -> Initrec: ... @dc.dataclass(slots=True) class Session: user_id: UserId user_name: str @dc.dataclass(slots=True) class PageState(Generic[M]): child_modules: list[M] = dc.field(default_factory=list) @dc.dataclass(slots=True, kw_only=True) class Page_MainPage_State(PageState[Module]): session: Session row_count: int = 0 @dc.dataclass(slots=True, kw_only=True) class Page_NewInitRec_State(PageState[Module]): session: Session @dc.dataclass(slots=True, kw_only=True) class Page_InitRecCompany_State(PageState[Module]): session: Session un_id: RecId | None = None locked: bool @dc.dataclass(slots=True, kw_only=True) class AutoFormConfig: model: type[FlatBaseModel] to_db: AutoFormToDb from_db: AutoFormFromDb # 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 @dc.dataclass(slots=True, kw_only=True) class AutoForm_State(PageState[Module]): session: Session cfg: AutoFormConfig rec_id: RecId | None = None form_data: dict[str, Any] | None = None locked: bool geloescht: bool = False @dc.dataclass(slots=True, kw_only=True) class Page_InitRecPerson_State(PageState[Module]): session: Session pers_id: RecId | None = None locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_State(PageState[Module]): session: Session vorgang_id: ConsId | None un_id: RecId | None = None pers_id: RecId | None = None titel: str = "TEST-TITEL" # add title later to GUI or build it in the background beratungs_typ: ConsultingType cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( default_factory=list ) geloescht: bool = False locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Linking_State(PageState[Module]): session: Session un_id: RecId | None = None pers_id: RecId | None = None beratungs_typ: ConsultingType @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_ConsultingSession_State(PageState[Module]): session: Session nutzer_id: UserId # must be set with the session (always known) nutzer_name: str # must be set with the session (always known) beratung_id: int | None # known and not known entries zeitstempel: datetime.datetime | None = None # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) ansprechpartner: str | None = None kommunikationsweg: str | None = None thema_crm_matrix: str | None = ( None # TODO should be linked to something, target unclear (need specification by customer) ) anmerkungen: str | None = None rueckmeldung: str | None = None ist_geloescht: bool = False locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): session: Session row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) locked: bool @dc.dataclass(slots=True, kw_only=True) class Page_CompanyProfile_State(PageState[Module]): session: Session rec_id: RecId | None = None # // Pydantic models 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) # ** InitRec # class Initrec_FromDb(BaseModel): # rec_id: RecId # geloescht: AwareDatetime | None # Metadaten_aktualisierung: AwareDatetime # form_data: dict[str, Any] class Initrec(BaseModel): rec_id: RecId | None geloescht: AwareDatetime | None = None db_data: dict[str, Any] 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 class Grunderfassung_Personen(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 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 # ** Consulting class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) vorgang_id: int | None un_id: int | None pers_id: int | None titel: str beratungs_typ: ConsultingType # default in SQLAlchemy with lambda and timezone-aware datetime erstellt: AwareDatetime | None = Field(default=None, exclude=True) aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above geloescht: AwareDatetime | None = None beratungen: list[Beratungsgespraech_Einzelgespraech] class Beratungsgespraech_Einzelgespraech(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) vorgang_id: int | None beratung_id: int | None nutzer_id: int nutzer_name: str zeitstempel: AwareDatetime ansprechpartner: str kommunikationsweg: str thema_crm_matrix: str | None anmerkungen: str | None rueckmeldung: str | None # default in SQLAlchemy with lambda and timezone-aware datetime erstellt: AwareDatetime | None = Field(default=None, exclude=True) aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above geloescht: AwareDatetime | None = None