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.
Files changed (44) hide show
  1. brynq_sdk_alight/__init__.py +1019 -0
  2. brynq_sdk_alight/address.py +72 -0
  3. brynq_sdk_alight/archive/flat_wrapper.py +139 -0
  4. brynq_sdk_alight/archive/hrxml_generator.py +280 -0
  5. brynq_sdk_alight/archive/managers.py +132 -0
  6. brynq_sdk_alight/archive/managers_generic.py +114 -0
  7. brynq_sdk_alight/archive/managers_old_complex.py +294 -0
  8. brynq_sdk_alight/archive/managers_simple.py +229 -0
  9. brynq_sdk_alight/employee.py +81 -0
  10. brynq_sdk_alight/job.py +89 -0
  11. brynq_sdk_alight/leave.py +97 -0
  12. brynq_sdk_alight/pay_elements.py +97 -0
  13. brynq_sdk_alight/salary.py +89 -0
  14. brynq_sdk_alight/schemas/__init__.py +26 -0
  15. brynq_sdk_alight/schemas/absence.py +83 -0
  16. brynq_sdk_alight/schemas/address.py +113 -0
  17. brynq_sdk_alight/schemas/employee.py +641 -0
  18. brynq_sdk_alight/schemas/generated_envelope_xsd_schema/__init__.py +38683 -0
  19. brynq_sdk_alight/schemas/generated_envelope_xsd_schema/process_pay_serv_emp.py +622264 -0
  20. brynq_sdk_alight/schemas/generated_xsd_schemas/__init__.py +10965 -0
  21. brynq_sdk_alight/schemas/generated_xsd_schemas/csec_person.py +39808 -0
  22. brynq_sdk_alight/schemas/generated_xsd_schemas/hrxml_indicative_data.py +90318 -0
  23. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_bod.py +33869 -0
  24. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_currency_code_iso_7_04.py +365 -0
  25. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_language_code_iso_7_04.py +16 -0
  26. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_mimemedia_type_code_iana_7_04.py +16 -0
  27. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_unit_code_unece_7_04.py +14 -0
  28. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_lists.py +535 -0
  29. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_qualified_data_types.py +84 -0
  30. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_unqualified_data_types.py +1449 -0
  31. brynq_sdk_alight/schemas/job.py +129 -0
  32. brynq_sdk_alight/schemas/leave.py +58 -0
  33. brynq_sdk_alight/schemas/payments.py +207 -0
  34. brynq_sdk_alight/schemas/salary.py +67 -0
  35. brynq_sdk_alight/schemas/termination.py +48 -0
  36. brynq_sdk_alight/schemas/timequota.py +66 -0
  37. brynq_sdk_alight/schemas/utils.py +452 -0
  38. brynq_sdk_alight/termination.py +103 -0
  39. brynq_sdk_alight/time_elements.py +121 -0
  40. brynq_sdk_alight/time_quotas.py +114 -0
  41. brynq_sdk_alight-1.0.0.dist-info/METADATA +20 -0
  42. brynq_sdk_alight-1.0.0.dist-info/RECORD +44 -0
  43. brynq_sdk_alight-1.0.0.dist-info/WHEEL +5 -0
  44. 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
+ )