brynq-sdk-alight 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brynq_sdk_alight/__init__.py +1019 -0
- brynq_sdk_alight/address.py +72 -0
- brynq_sdk_alight/archive/flat_wrapper.py +139 -0
- brynq_sdk_alight/archive/hrxml_generator.py +280 -0
- brynq_sdk_alight/archive/managers.py +132 -0
- brynq_sdk_alight/archive/managers_generic.py +114 -0
- brynq_sdk_alight/archive/managers_old_complex.py +294 -0
- brynq_sdk_alight/archive/managers_simple.py +229 -0
- brynq_sdk_alight/employee.py +81 -0
- brynq_sdk_alight/job.py +89 -0
- brynq_sdk_alight/leave.py +97 -0
- brynq_sdk_alight/pay_elements.py +97 -0
- brynq_sdk_alight/salary.py +89 -0
- brynq_sdk_alight/schemas/__init__.py +26 -0
- brynq_sdk_alight/schemas/absence.py +83 -0
- brynq_sdk_alight/schemas/address.py +113 -0
- brynq_sdk_alight/schemas/employee.py +641 -0
- brynq_sdk_alight/schemas/generated_envelope_xsd_schema/__init__.py +38683 -0
- brynq_sdk_alight/schemas/generated_envelope_xsd_schema/process_pay_serv_emp.py +622264 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/__init__.py +10965 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/csec_person.py +39808 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/hrxml_indicative_data.py +90318 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_bod.py +33869 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_currency_code_iso_7_04.py +365 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_language_code_iso_7_04.py +16 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_mimemedia_type_code_iana_7_04.py +16 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_unit_code_unece_7_04.py +14 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_lists.py +535 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_qualified_data_types.py +84 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_unqualified_data_types.py +1449 -0
- brynq_sdk_alight/schemas/job.py +129 -0
- brynq_sdk_alight/schemas/leave.py +58 -0
- brynq_sdk_alight/schemas/payments.py +207 -0
- brynq_sdk_alight/schemas/salary.py +67 -0
- brynq_sdk_alight/schemas/termination.py +48 -0
- brynq_sdk_alight/schemas/timequota.py +66 -0
- brynq_sdk_alight/schemas/utils.py +452 -0
- brynq_sdk_alight/termination.py +103 -0
- brynq_sdk_alight/time_elements.py +121 -0
- brynq_sdk_alight/time_quotas.py +114 -0
- brynq_sdk_alight-1.0.0.dist-info/METADATA +20 -0
- brynq_sdk_alight-1.0.0.dist-info/RECORD +44 -0
- brynq_sdk_alight-1.0.0.dist-info/WHEEL +5 -0
- brynq_sdk_alight-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared conversion and nesting utilities for flat -> nested XSD mapping.
|
|
3
|
+
Composition-first alternative to inheriting a custom BaseModel.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import datetime
|
|
7
|
+
from typing import Any, Dict, Optional, Type, get_args, get_origin, Annotated
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
from xsdata.models.datatype import XmlDate, XmlDateTime, XmlTime, XmlPeriod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FieldMeta:
|
|
15
|
+
__slots__ = ("name", "field")
|
|
16
|
+
|
|
17
|
+
def __init__(self, name: str, field: FieldInfo):
|
|
18
|
+
self.name = name
|
|
19
|
+
self.field = field
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_MODEL_FIELD_CACHE: Dict[Type[PydanticBaseModel], Dict[str, _FieldMeta]] = {}
|
|
23
|
+
_IS_LIST_CACHE: Dict[int, bool] = {}
|
|
24
|
+
_LIST_ITEM_TYPE_CACHE: Dict[int, Optional[Type[Any]]] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_to_nested_path(nested: Dict[str, Any], path: str, value: Any) -> None:
|
|
28
|
+
"""Add a value to a nested dict at a dot-separated path with optional list indices."""
|
|
29
|
+
parts = path.split('.')
|
|
30
|
+
current = nested
|
|
31
|
+
|
|
32
|
+
# Navigate to parent
|
|
33
|
+
for part in parts[:-1]:
|
|
34
|
+
name = part
|
|
35
|
+
index: Optional[int] = None
|
|
36
|
+
if '[' in part and part.endswith(']'):
|
|
37
|
+
name = part[: part.index('[')]
|
|
38
|
+
try:
|
|
39
|
+
index = int(part[part.index('[') + 1 : -1])
|
|
40
|
+
except Exception:
|
|
41
|
+
index = None
|
|
42
|
+
|
|
43
|
+
if name not in current:
|
|
44
|
+
current[name] = [] if index is not None else {}
|
|
45
|
+
else:
|
|
46
|
+
if index is None and not isinstance(current[name], dict):
|
|
47
|
+
current[name] = {"value": current[name]}
|
|
48
|
+
if index is not None and not isinstance(current[name], list):
|
|
49
|
+
current[name] = []
|
|
50
|
+
|
|
51
|
+
if index is None:
|
|
52
|
+
current = current[name]
|
|
53
|
+
else:
|
|
54
|
+
while len(current[name]) <= index:
|
|
55
|
+
current[name].append(None)
|
|
56
|
+
if not isinstance(current[name][index], dict):
|
|
57
|
+
existing_value = current[name][index]
|
|
58
|
+
current[name][index] = {"value": existing_value} if existing_value is not None else {}
|
|
59
|
+
current = current[name][index]
|
|
60
|
+
|
|
61
|
+
# Leaf
|
|
62
|
+
leaf = parts[-1]
|
|
63
|
+
name = leaf
|
|
64
|
+
index: Optional[int] = None
|
|
65
|
+
if '[' in leaf and leaf.endswith(']'):
|
|
66
|
+
name = leaf[: leaf.index('[')]
|
|
67
|
+
try:
|
|
68
|
+
index = int(leaf[leaf.index('[') + 1 : -1])
|
|
69
|
+
except Exception:
|
|
70
|
+
index = None
|
|
71
|
+
|
|
72
|
+
if index is None:
|
|
73
|
+
current[name] = value
|
|
74
|
+
else:
|
|
75
|
+
if name not in current or not isinstance(current[name], list):
|
|
76
|
+
current[name] = []
|
|
77
|
+
while len(current[name]) <= index:
|
|
78
|
+
current[name].append(None)
|
|
79
|
+
current[name][index] = value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _unwrap_annotated(t):
|
|
83
|
+
while get_origin(t) is Annotated:
|
|
84
|
+
args = get_args(t)
|
|
85
|
+
if args:
|
|
86
|
+
t = args[0]
|
|
87
|
+
else:
|
|
88
|
+
break
|
|
89
|
+
return t
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_wrapper_model(model_type) -> bool:
|
|
93
|
+
return (
|
|
94
|
+
isinstance(model_type, type)
|
|
95
|
+
and issubclass(model_type, PydanticBaseModel)
|
|
96
|
+
and hasattr(model_type, "model_fields")
|
|
97
|
+
and "value" in getattr(model_type, "model_fields", {})
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_list_field(field_type) -> bool:
|
|
102
|
+
cache_key = id(field_type)
|
|
103
|
+
if cache_key in _IS_LIST_CACHE:
|
|
104
|
+
return _IS_LIST_CACHE[cache_key]
|
|
105
|
+
|
|
106
|
+
field_type = _unwrap_annotated(field_type)
|
|
107
|
+
origin = get_origin(field_type)
|
|
108
|
+
if origin is list or str(origin) == 'typing.List':
|
|
109
|
+
_IS_LIST_CACHE[cache_key] = True
|
|
110
|
+
return True
|
|
111
|
+
# Optional[List[T]] / Union[List[T], None]
|
|
112
|
+
if str(origin) == 'typing.Union':
|
|
113
|
+
for arg in get_args(field_type) or []:
|
|
114
|
+
arg = _unwrap_annotated(arg)
|
|
115
|
+
inner_origin = get_origin(arg)
|
|
116
|
+
if inner_origin is list or str(inner_origin) == 'typing.List':
|
|
117
|
+
_IS_LIST_CACHE[cache_key] = True
|
|
118
|
+
return True
|
|
119
|
+
_IS_LIST_CACHE[cache_key] = False
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_list_item_type(field_type):
|
|
124
|
+
cache_key = id(field_type)
|
|
125
|
+
if cache_key in _LIST_ITEM_TYPE_CACHE:
|
|
126
|
+
return _LIST_ITEM_TYPE_CACHE[cache_key]
|
|
127
|
+
|
|
128
|
+
field_type = _unwrap_annotated(field_type)
|
|
129
|
+
if get_origin(field_type) is list:
|
|
130
|
+
args = get_args(field_type)
|
|
131
|
+
if args:
|
|
132
|
+
value = _unwrap_annotated(args[0])
|
|
133
|
+
_LIST_ITEM_TYPE_CACHE[cache_key] = value
|
|
134
|
+
return value
|
|
135
|
+
# Optional[List[T]]
|
|
136
|
+
origin = get_origin(field_type)
|
|
137
|
+
if str(origin) == 'typing.Union':
|
|
138
|
+
for arg in get_args(field_type) or []:
|
|
139
|
+
arg = _unwrap_annotated(arg)
|
|
140
|
+
if get_origin(arg) is list:
|
|
141
|
+
inner = get_args(arg)
|
|
142
|
+
if inner:
|
|
143
|
+
value = _unwrap_annotated(inner[0])
|
|
144
|
+
_LIST_ITEM_TYPE_CACHE[cache_key] = value
|
|
145
|
+
return value
|
|
146
|
+
_LIST_ITEM_TYPE_CACHE[cache_key] = None
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def unwrap_optional_and_list(field_type):
|
|
151
|
+
field_type = _unwrap_annotated(field_type)
|
|
152
|
+
origin = get_origin(field_type)
|
|
153
|
+
if origin is type(Optional[str]) or str(origin) == 'typing.Union':
|
|
154
|
+
args = get_args(field_type)
|
|
155
|
+
if args:
|
|
156
|
+
for arg in args:
|
|
157
|
+
if arg is not type(None): # noqa: E721
|
|
158
|
+
field_type = _unwrap_annotated(arg)
|
|
159
|
+
break
|
|
160
|
+
if get_origin(field_type) is list:
|
|
161
|
+
args = get_args(field_type)
|
|
162
|
+
if args:
|
|
163
|
+
field_type = _unwrap_annotated(args[0])
|
|
164
|
+
return field_type
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def convert_datetime_to_xml(dt_value: datetime.date | datetime.datetime, target_type) -> Any:
|
|
168
|
+
"""Convert Python datetime/date to appropriate XML type."""
|
|
169
|
+
if isinstance(dt_value, datetime.date) and not isinstance(dt_value, datetime.datetime):
|
|
170
|
+
return XmlDate(dt_value.year, dt_value.month, dt_value.day)
|
|
171
|
+
if isinstance(dt_value, datetime.datetime):
|
|
172
|
+
return XmlDateTime(
|
|
173
|
+
dt_value.year,
|
|
174
|
+
dt_value.month,
|
|
175
|
+
dt_value.day,
|
|
176
|
+
dt_value.hour,
|
|
177
|
+
dt_value.minute,
|
|
178
|
+
dt_value.second,
|
|
179
|
+
dt_value.microsecond // 1000,
|
|
180
|
+
)
|
|
181
|
+
return dt_value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def convert_date_string_to_xml(date_str: str, field_type, field_name: str) -> Any:
|
|
185
|
+
target_type = get_xml_date_type_from_annotation(field_type)
|
|
186
|
+
if target_type is None:
|
|
187
|
+
lname = field_name.lower()
|
|
188
|
+
if 'datetime' in lname or 'timestamp' in lname:
|
|
189
|
+
target_type = XmlDateTime
|
|
190
|
+
elif 'time' in lname and 'date' not in lname:
|
|
191
|
+
target_type = XmlTime
|
|
192
|
+
elif 'period' in lname:
|
|
193
|
+
target_type = XmlPeriod
|
|
194
|
+
else:
|
|
195
|
+
target_type = XmlDate
|
|
196
|
+
return convert_date_string_to_xml_for_type(date_str, target_type)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def convert_date_string_to_xml_for_type(date_str: str, target_type) -> Any:
|
|
200
|
+
try:
|
|
201
|
+
if target_type == XmlDate or str(target_type).endswith('XmlDate'):
|
|
202
|
+
return XmlDate.from_string(date_str)
|
|
203
|
+
if target_type == XmlDateTime or str(target_type).endswith('XmlDateTime'):
|
|
204
|
+
return XmlDateTime.from_string(date_str)
|
|
205
|
+
if target_type == XmlTime or str(target_type).endswith('XmlTime'):
|
|
206
|
+
return XmlTime.from_string(date_str)
|
|
207
|
+
if target_type == XmlPeriod or str(target_type).endswith('XmlPeriod'):
|
|
208
|
+
return XmlPeriod.from_string(date_str)
|
|
209
|
+
return date_str
|
|
210
|
+
except Exception:
|
|
211
|
+
return date_str
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_xml_date_type_from_annotation(field_type) -> Optional[Type]:
|
|
215
|
+
field_type = _unwrap_annotated(field_type)
|
|
216
|
+
if field_type in (XmlDate, XmlDateTime, XmlTime, XmlPeriod):
|
|
217
|
+
return field_type
|
|
218
|
+
type_str = str(field_type)
|
|
219
|
+
if 'XmlDate' in type_str and 'XmlDateTime' not in type_str:
|
|
220
|
+
return XmlDate
|
|
221
|
+
if 'XmlDateTime' in type_str:
|
|
222
|
+
return XmlDateTime
|
|
223
|
+
if 'XmlTime' in type_str:
|
|
224
|
+
return XmlTime
|
|
225
|
+
if 'XmlPeriod' in type_str:
|
|
226
|
+
return XmlPeriod
|
|
227
|
+
origin = getattr(field_type, '__origin__', None)
|
|
228
|
+
if str(origin) == 'typing.Union':
|
|
229
|
+
for arg in get_args(field_type) or []:
|
|
230
|
+
arg = _unwrap_annotated(arg)
|
|
231
|
+
if arg in (XmlDate, XmlDateTime, XmlTime, XmlPeriod):
|
|
232
|
+
return arg
|
|
233
|
+
elif hasattr(arg, '__name__'):
|
|
234
|
+
name = arg.__name__
|
|
235
|
+
if name == 'XmlDate':
|
|
236
|
+
return XmlDate
|
|
237
|
+
if name == 'XmlDateTime':
|
|
238
|
+
return XmlDateTime
|
|
239
|
+
if name == 'XmlTime':
|
|
240
|
+
return XmlTime
|
|
241
|
+
if name == 'XmlPeriod':
|
|
242
|
+
return XmlPeriod
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def looks_like_date(value: str) -> bool:
|
|
247
|
+
if not isinstance(value, str):
|
|
248
|
+
return False
|
|
249
|
+
if len(value) >= 10 and value[4] == '-' and value[7] == '-':
|
|
250
|
+
try:
|
|
251
|
+
year = int(value[0:4])
|
|
252
|
+
month = int(value[5:7])
|
|
253
|
+
day = int(value[8:10])
|
|
254
|
+
if 1 <= month <= 12 and 1 <= day <= 31 and year >= 1900:
|
|
255
|
+
return True
|
|
256
|
+
except (ValueError, IndexError):
|
|
257
|
+
pass
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def post_process_nested_data(nested_data: Dict[str, Any], model: PydanticBaseModel) -> None:
|
|
262
|
+
"""Ensure wrapper/list structures and primitive coercions align with the schema model."""
|
|
263
|
+
|
|
264
|
+
if not hasattr(model, 'model_fields'):
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
_normalize_node(nested_data, model, model.__name__ or "root")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _normalize_node(node: Dict[str, Any], model: PydanticBaseModel, path: str) -> None:
|
|
271
|
+
if not isinstance(node, dict) or not hasattr(model, 'model_fields'):
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
field_lookup = _get_model_field_cache(model)
|
|
275
|
+
|
|
276
|
+
for key, value in list(node.items()):
|
|
277
|
+
meta = field_lookup.get(key)
|
|
278
|
+
if meta is None:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
field = meta.field
|
|
282
|
+
raw_annotation = field.annotation
|
|
283
|
+
current_path = f"{path}.{field.alias or meta.name}"
|
|
284
|
+
|
|
285
|
+
if value is None:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if is_list_field(raw_annotation):
|
|
289
|
+
node[key] = _normalize_list(value, raw_annotation, current_path)
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
base_annotation = unwrap_optional_and_list(_unwrap_annotated(raw_annotation))
|
|
293
|
+
|
|
294
|
+
if isinstance(base_annotation, type) and issubclass(base_annotation, PydanticBaseModel):
|
|
295
|
+
if is_wrapper_model(base_annotation):
|
|
296
|
+
node[key] = _ensure_wrapper(value, base_annotation, current_path)
|
|
297
|
+
else:
|
|
298
|
+
if isinstance(value, dict):
|
|
299
|
+
_normalize_node(value, base_annotation, current_path)
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
node[key] = _coerce_primitive_value(value, raw_annotation, current_path)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _normalize_list(value: Any, annotation, path: str) -> list:
|
|
306
|
+
items = value if isinstance(value, list) else [value]
|
|
307
|
+
items = [item for item in items if item not in (None, {})]
|
|
308
|
+
|
|
309
|
+
item_annotation = get_list_item_type(annotation)
|
|
310
|
+
if item_annotation is None:
|
|
311
|
+
return items
|
|
312
|
+
|
|
313
|
+
base_item = unwrap_optional_and_list(_unwrap_annotated(item_annotation))
|
|
314
|
+
|
|
315
|
+
if isinstance(base_item, type) and issubclass(base_item, PydanticBaseModel):
|
|
316
|
+
normalized: list[Any] = []
|
|
317
|
+
for idx, item in enumerate(items):
|
|
318
|
+
item_path = f"{path}[{idx}]"
|
|
319
|
+
if item is None:
|
|
320
|
+
continue
|
|
321
|
+
if is_wrapper_model(base_item):
|
|
322
|
+
normalized.append(_ensure_wrapper(item, base_item, item_path))
|
|
323
|
+
elif isinstance(item, dict):
|
|
324
|
+
_normalize_node(item, base_item, item_path)
|
|
325
|
+
normalized.append(item)
|
|
326
|
+
else:
|
|
327
|
+
normalized.append(item)
|
|
328
|
+
return normalized
|
|
329
|
+
|
|
330
|
+
return [_coerce_primitive_value(item, item_annotation, f"{path}[{idx}]") for idx, item in enumerate(items)]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ensure_wrapper(value: Any, wrapper_type: Type[PydanticBaseModel], path: str) -> Dict[str, Any]:
|
|
334
|
+
value_field = wrapper_type.model_fields['value']
|
|
335
|
+
value_annotation = value_field.annotation
|
|
336
|
+
|
|
337
|
+
if isinstance(value, dict) and 'value' in value:
|
|
338
|
+
coerced = _coerce_primitive_value(value['value'], value_annotation, f"{path}.value")
|
|
339
|
+
value['value'] = coerced
|
|
340
|
+
return value
|
|
341
|
+
|
|
342
|
+
coerced = _coerce_primitive_value(value, value_annotation, f"{path}.value")
|
|
343
|
+
return {"value": coerced}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _coerce_primitive_value(value: Any, annotation, path: str) -> Any:
|
|
347
|
+
target_annotation = unwrap_optional_and_list(_unwrap_annotated(annotation))
|
|
348
|
+
|
|
349
|
+
if value is None:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
# Handle Union of XML date/time types explicitly (attributes like valid_from/valid_to)
|
|
353
|
+
origin = get_origin(target_annotation)
|
|
354
|
+
if str(origin) == 'typing.Union':
|
|
355
|
+
union_args = [ _unwrap_annotated(a) for a in (get_args(target_annotation) or []) ]
|
|
356
|
+
has_xml_date_union = any(a in (XmlDate, XmlDateTime, XmlTime, XmlPeriod) for a in union_args)
|
|
357
|
+
if has_xml_date_union:
|
|
358
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
359
|
+
# Prefer XmlDate for date, XmlDateTime for datetime
|
|
360
|
+
return convert_datetime_to_xml(value, XmlDateTime if isinstance(value, datetime.datetime) else XmlDate)
|
|
361
|
+
if isinstance(value, str) and looks_like_date(value):
|
|
362
|
+
# Default to XmlDate for strings that look like dates
|
|
363
|
+
return convert_date_string_to_xml_for_type(value, XmlDate)
|
|
364
|
+
|
|
365
|
+
xml_target = get_xml_date_type_from_annotation(target_annotation)
|
|
366
|
+
if xml_target is not None:
|
|
367
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
368
|
+
return convert_datetime_to_xml(value, xml_target)
|
|
369
|
+
if isinstance(value, str) and looks_like_date(value):
|
|
370
|
+
return convert_date_string_to_xml_for_type(value, xml_target)
|
|
371
|
+
|
|
372
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
373
|
+
return convert_datetime_to_xml(value, target_annotation)
|
|
374
|
+
|
|
375
|
+
if isinstance(value, str) and looks_like_date(value):
|
|
376
|
+
return convert_date_string_to_xml(value, target_annotation, path.split('.')[-1])
|
|
377
|
+
|
|
378
|
+
if target_annotation is str and isinstance(value, bool):
|
|
379
|
+
return "true" if value else "false"
|
|
380
|
+
|
|
381
|
+
return value
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _get_model_field_cache(model: Type[PydanticBaseModel]) -> Dict[str, _FieldMeta]:
|
|
385
|
+
cached = _MODEL_FIELD_CACHE.get(model)
|
|
386
|
+
if cached is not None:
|
|
387
|
+
return cached
|
|
388
|
+
|
|
389
|
+
mapping: Dict[str, _FieldMeta] = {}
|
|
390
|
+
for name, field in getattr(model, "model_fields", {}).items():
|
|
391
|
+
meta = _FieldMeta(name=name, field=field)
|
|
392
|
+
mapping[name] = meta
|
|
393
|
+
alias = getattr(field, "alias", None)
|
|
394
|
+
if alias:
|
|
395
|
+
mapping[alias] = meta
|
|
396
|
+
_MODEL_FIELD_CACHE[model] = mapping
|
|
397
|
+
return mapping
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def construct_model(model: Type[PydanticBaseModel], data: Dict[str, Any]) -> PydanticBaseModel:
|
|
401
|
+
"""
|
|
402
|
+
Construct a Pydantic model instance without full validation by recursively
|
|
403
|
+
instantiating only the fields present in the provided data dictionary.
|
|
404
|
+
"""
|
|
405
|
+
if isinstance(data, model):
|
|
406
|
+
return data
|
|
407
|
+
if not isinstance(data, dict):
|
|
408
|
+
raise TypeError(f"Expected dict to construct {model.__name__}, got {type(data).__name__}")
|
|
409
|
+
|
|
410
|
+
lookup = _get_model_field_cache(model)
|
|
411
|
+
values: Dict[str, Any] = {}
|
|
412
|
+
|
|
413
|
+
for key, value in data.items():
|
|
414
|
+
if value is None:
|
|
415
|
+
continue
|
|
416
|
+
meta = lookup.get(key)
|
|
417
|
+
if meta is None:
|
|
418
|
+
continue
|
|
419
|
+
if meta.name in values:
|
|
420
|
+
continue
|
|
421
|
+
field = meta.field
|
|
422
|
+
values[meta.name] = _prepare_field_value(field.annotation, value)
|
|
423
|
+
|
|
424
|
+
return model.model_construct(_fields_set=set(values.keys()), **values)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _prepare_field_value(annotation, value: Any) -> Any:
|
|
428
|
+
if value is None:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
if is_list_field(annotation):
|
|
432
|
+
items = value if isinstance(value, list) else [value]
|
|
433
|
+
item_type = get_list_item_type(annotation)
|
|
434
|
+
if item_type is None:
|
|
435
|
+
return items
|
|
436
|
+
prepared = [_prepare_field_value(item_type, item) for item in items if item not in (None, {})]
|
|
437
|
+
return prepared
|
|
438
|
+
|
|
439
|
+
base_annotation = unwrap_optional_and_list(_unwrap_annotated(annotation))
|
|
440
|
+
|
|
441
|
+
if isinstance(base_annotation, type) and issubclass(base_annotation, PydanticBaseModel):
|
|
442
|
+
if isinstance(value, base_annotation):
|
|
443
|
+
return value
|
|
444
|
+
if isinstance(value, dict):
|
|
445
|
+
return construct_model(base_annotation, value)
|
|
446
|
+
if isinstance(value, list):
|
|
447
|
+
return [
|
|
448
|
+
construct_model(base_annotation, element) if isinstance(element, dict) else element
|
|
449
|
+
for element in value
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
return value
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
from .schemas import Termination as TerminationModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Termination:
|
|
7
|
+
"""
|
|
8
|
+
High-level Termination API.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
alight = Alight()
|
|
12
|
+
xml = alight.termination.create(data={...}, person_id="35561", employee_id="35561ZZGB")
|
|
13
|
+
xml = alight.termination.update(data={...}, person_id="35561", employee_id="35561ZZGB")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: Any):
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def create(
|
|
20
|
+
self,
|
|
21
|
+
data: Dict[str, Any],
|
|
22
|
+
*,
|
|
23
|
+
person_id: str,
|
|
24
|
+
employee_id: str,
|
|
25
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
26
|
+
employee_data: Optional[Dict[str, Any]] = None,
|
|
27
|
+
logical_id: Optional[str] = None,
|
|
28
|
+
envelope_options: Optional[Dict[str, Any]] = None,
|
|
29
|
+
pretty_print: bool = True,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Create termination entry (actionCode=ADD). Returns XML string.
|
|
33
|
+
Requires both person_id and employee_id.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> alight.termination.create(
|
|
37
|
+
... data={"termination_reason": "RESIGNATION", "termination_date": "2024-06-30"},
|
|
38
|
+
... person_id="35561",
|
|
39
|
+
... employee_id="35561ZZGB",
|
|
40
|
+
... extension_data={"last_day_worked": "2024-06-28"},
|
|
41
|
+
... )
|
|
42
|
+
"""
|
|
43
|
+
termination_model = TerminationModel(**data)
|
|
44
|
+
payload: Dict[str, Any] = {
|
|
45
|
+
"person_id": person_id,
|
|
46
|
+
"employee_id": employee_id,
|
|
47
|
+
"termination": termination_model.model_dump(exclude_none=True, by_alias=True),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if employee_data:
|
|
51
|
+
payload.update(employee_data)
|
|
52
|
+
|
|
53
|
+
return self._client.generate_employee_xml(
|
|
54
|
+
employee=payload,
|
|
55
|
+
action_code="ADD",
|
|
56
|
+
logical_id=logical_id,
|
|
57
|
+
extension_data=extension_data,
|
|
58
|
+
envelope_options=envelope_options,
|
|
59
|
+
pretty_print=pretty_print,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def update(
|
|
63
|
+
self,
|
|
64
|
+
data: Dict[str, Any],
|
|
65
|
+
*,
|
|
66
|
+
person_id: str,
|
|
67
|
+
employee_id: str,
|
|
68
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
69
|
+
employee_data: Optional[Dict[str, Any]] = None,
|
|
70
|
+
logical_id: Optional[str] = None,
|
|
71
|
+
envelope_options: Optional[Dict[str, Any]] = None,
|
|
72
|
+
pretty_print: bool = True,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Update termination entry (actionCode=CHANGE). Returns XML string.
|
|
76
|
+
Requires both person_id and employee_id.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> alight.termination.update(
|
|
80
|
+
... data={"termination_reason": "DISMISSAL", "termination_date": "2024-07-15"},
|
|
81
|
+
... person_id="35561",
|
|
82
|
+
... employee_id="35561ZZGB",
|
|
83
|
+
... employee_data={"last_day_worked": "2024-07-10"},
|
|
84
|
+
... )
|
|
85
|
+
"""
|
|
86
|
+
termination_model = TerminationModel(**data)
|
|
87
|
+
payload: Dict[str, Any] = {
|
|
88
|
+
"person_id": person_id,
|
|
89
|
+
"employee_id": employee_id,
|
|
90
|
+
"termination": termination_model.model_dump(exclude_none=True, by_alias=True),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if employee_data:
|
|
94
|
+
payload.update(employee_data)
|
|
95
|
+
|
|
96
|
+
return self._client.generate_employee_xml(
|
|
97
|
+
employee=payload,
|
|
98
|
+
action_code="CHANGE",
|
|
99
|
+
logical_id=logical_id,
|
|
100
|
+
extension_data=extension_data,
|
|
101
|
+
envelope_options=envelope_options,
|
|
102
|
+
pretty_print=pretty_print,
|
|
103
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from .schemas.absence import Absence, Absences
|
|
4
|
+
from .schemas.payments import PayServEmpExtensionCreate
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TimeElements:
|
|
8
|
+
"""
|
|
9
|
+
High-level Time Elements API. Sends Absences via PayServEmpExtension.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
alight = Alight()
|
|
13
|
+
xml = alight.time_elements.create(absences=[...], person_id="35561", employee_id="35561ZZGB")
|
|
14
|
+
xml = alight.time_elements.update(absences=[...], person_id="35561", employee_id="35561ZZGB")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: Any):
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
def _build_extension_with_time_elements(self, absences: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Dict[str, Any]:
|
|
21
|
+
"""
|
|
22
|
+
Transform raw absence dictionaries into the nested structure required by the Alight extension.
|
|
23
|
+
|
|
24
|
+
Accepts either a single absence dict or a list; normalizes to the schema-backed
|
|
25
|
+
`PayServEmpExtensionCreate` payload before handing off to XML generation.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> self._build_extension_with_time_elements(
|
|
29
|
+
... {"absence_reason": "VAC", "valid_from": "2024-01-01", "units": "8"}
|
|
30
|
+
... )
|
|
31
|
+
"""
|
|
32
|
+
# Normalize input to list of dicts
|
|
33
|
+
if isinstance(absences, dict):
|
|
34
|
+
normalized = [absences]
|
|
35
|
+
else:
|
|
36
|
+
normalized = absences
|
|
37
|
+
# Let Pydantic accept native date types; XSD post-processing will wrap
|
|
38
|
+
absence_models = [Absence(**dict(a)) for a in normalized]
|
|
39
|
+
wrapper = Absences(absences=absence_models)
|
|
40
|
+
extension_model = PayServEmpExtensionCreate(time_elements=wrapper)
|
|
41
|
+
return extension_model.to_nested_dict()
|
|
42
|
+
|
|
43
|
+
def create(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
absences: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
47
|
+
person_id: str,
|
|
48
|
+
employee_id: str,
|
|
49
|
+
logical_id: Optional[str] = None,
|
|
50
|
+
envelope_options: Optional[Dict[str, Any]] = None,
|
|
51
|
+
pretty_print: bool = True,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Generate an ADD envelope that carries absence data through the extension channel.
|
|
55
|
+
|
|
56
|
+
Useful when feeding single-day absences from a flat export—pass the dict directly and the helper
|
|
57
|
+
will wrap it in the schema-specific containers.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> alight.time_elements.create(
|
|
61
|
+
... absences={"absence_reason": "VAC", "valid_from": "2024-01-01", "units": "8"},
|
|
62
|
+
... person_id="35561",
|
|
63
|
+
... employee_id="35561ZZGB",
|
|
64
|
+
... )
|
|
65
|
+
"""
|
|
66
|
+
extension_data = self._build_extension_with_time_elements(absences)
|
|
67
|
+
|
|
68
|
+
payload = {
|
|
69
|
+
"person_id": person_id,
|
|
70
|
+
"employee_id": employee_id,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return self._client.generate_employee_xml(
|
|
74
|
+
employee=payload,
|
|
75
|
+
action_code="ADD",
|
|
76
|
+
logical_id=logical_id,
|
|
77
|
+
extension_data=extension_data,
|
|
78
|
+
envelope_options=envelope_options,
|
|
79
|
+
pretty_print=pretty_print,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def update(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
absences: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
86
|
+
person_id: str,
|
|
87
|
+
employee_id: str,
|
|
88
|
+
logical_id: Optional[str] = None,
|
|
89
|
+
envelope_options: Optional[Dict[str, Any]] = None,
|
|
90
|
+
pretty_print: bool = True,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Generate a CHANGE envelope for absence data using the same normalization pipeline as `create`.
|
|
94
|
+
|
|
95
|
+
Downstream systems rely on the CHANGE action code to differentiate incremental corrections from
|
|
96
|
+
brand-new entries.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> alight.time_elements.update(
|
|
100
|
+
... absences=[
|
|
101
|
+
... {"absence_reason": "VAC", "valid_from": "2024-01-01", "valid_to": "2024-01-02", "units": "16"}
|
|
102
|
+
... ],
|
|
103
|
+
... person_id="35561",
|
|
104
|
+
... employee_id="35561ZZGB",
|
|
105
|
+
... )
|
|
106
|
+
"""
|
|
107
|
+
extension_data = self._build_extension_with_time_elements(absences)
|
|
108
|
+
|
|
109
|
+
payload = {
|
|
110
|
+
"person_id": person_id,
|
|
111
|
+
"employee_id": employee_id,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return self._client.generate_employee_xml(
|
|
115
|
+
employee=payload,
|
|
116
|
+
action_code="CHANGE",
|
|
117
|
+
logical_id=logical_id,
|
|
118
|
+
extension_data=extension_data,
|
|
119
|
+
envelope_options=envelope_options,
|
|
120
|
+
pretty_print=pretty_print,
|
|
121
|
+
)
|