generated from dopt-python/py311
638 lines
18 KiB
Python
638 lines
18 KiB
Python
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
|