brynq-sdk-bob 2.4.0__tar.gz → 2.4.2__tar.gz
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_bob-2.4.0 → brynq_sdk_bob-2.4.2}/PKG-INFO +1 -1
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/__init__.py +2 -0
- brynq_sdk_bob-2.4.2/brynq_sdk_bob/custom_tables.py +75 -0
- brynq_sdk_bob-2.4.0/brynq_sdk_bob/people.py → brynq_sdk_bob-2.4.2/brynq_sdk_bob/payroll_history.py +1 -12
- brynq_sdk_bob-2.4.2/brynq_sdk_bob/people.py +340 -0
- brynq_sdk_bob-2.4.2/brynq_sdk_bob/schemas/custom_tables.py +27 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/payments.py +1 -1
- brynq_sdk_bob-2.4.2/brynq_sdk_bob/schemas/payroll_history.py +24 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/people.py +4 -4
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/timeoff.py +10 -9
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/timeoff.py +3 -7
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/PKG-INFO +1 -1
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/SOURCES.txt +2 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/setup.py +1 -1
- brynq_sdk_bob-2.4.0/brynq_sdk_bob/custom_tables.py +0 -36
- brynq_sdk_bob-2.4.0/brynq_sdk_bob/schemas/custom_tables.py +0 -11
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/bank.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/company.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/documents.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/employment.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/named_lists.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/payments.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/salaries.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/__init__.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/bank.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/employment.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/named_lists.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/salary.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/schemas/work.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob/work.py +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/dependency_links.txt +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/not-zip-safe +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/requires.txt +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/brynq_sdk_bob.egg-info/top_level.txt +0 -0
- {brynq_sdk_bob-2.4.0 → brynq_sdk_bob-2.4.2}/setup.cfg +0 -0
|
@@ -16,6 +16,7 @@ from .people import People
|
|
|
16
16
|
from .salaries import Salaries
|
|
17
17
|
from .timeoff import TimeOff
|
|
18
18
|
from .work import Work
|
|
19
|
+
from .custom_tables import CustomTables
|
|
19
20
|
|
|
20
21
|
class Bob(BrynQ):
|
|
21
22
|
def __init__(self, system_type: Optional[Literal['source', 'target']] = None, test_environment: bool = True, debug: bool = False, target_system: str = None):
|
|
@@ -38,6 +39,7 @@ class Bob(BrynQ):
|
|
|
38
39
|
self.documents = CustomDocuments(self)
|
|
39
40
|
self.companies = Company(self)
|
|
40
41
|
self.named_lists = NamedLists(self)
|
|
42
|
+
self.custom_tables = CustomTables(self)
|
|
41
43
|
self.data_interface_id = os.getenv("DATA_INTERFACE_ID")
|
|
42
44
|
self.debug = debug
|
|
43
45
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from brynq_sdk_functions import Functions
|
|
3
|
+
from .schemas.custom_tables import CustomTableSchema, CustomTableMetadataSchema
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CustomTables:
|
|
7
|
+
def __init__(self, bob):
|
|
8
|
+
self.bob = bob
|
|
9
|
+
self.schema = CustomTableSchema
|
|
10
|
+
|
|
11
|
+
def get(self, employee_id: str, custom_table_id: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
12
|
+
"""
|
|
13
|
+
Get custom table data for an employee
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
employee_id: The employee ID
|
|
17
|
+
custom_table_id: The custom table ID
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A tuple of (valid_data, invalid_data) as pandas DataFrames
|
|
21
|
+
"""
|
|
22
|
+
resp = self.bob.session.get(url=f"{self.bob.base_url}people/custom-tables/{employee_id}/{custom_table_id}")
|
|
23
|
+
resp.raise_for_status()
|
|
24
|
+
data = resp.json()
|
|
25
|
+
|
|
26
|
+
# Normalize the nested JSON response
|
|
27
|
+
df = pd.json_normalize(
|
|
28
|
+
data,
|
|
29
|
+
record_path=['values']
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
df['employee_id'] = employee_id
|
|
33
|
+
valid_data, invalid_data = Functions.validate_data(df=df, schema=self.schema, debug=True)
|
|
34
|
+
|
|
35
|
+
return valid_data, invalid_data
|
|
36
|
+
|
|
37
|
+
def get_metadata(self) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
38
|
+
"""
|
|
39
|
+
Get metadata for all custom tables
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A tuple of (valid_data, invalid_data) as pandas DataFrames containing table and column metadata
|
|
43
|
+
"""
|
|
44
|
+
url = f"{self.bob.base_url}people/custom-tables/metadata"
|
|
45
|
+
resp = self.bob.session.get(url=url)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
data = resp.json()
|
|
48
|
+
|
|
49
|
+
# Flatten the nested structure - create one row per column with table info repeated
|
|
50
|
+
rows = []
|
|
51
|
+
for table in data.get('tables', []):
|
|
52
|
+
table_info = {
|
|
53
|
+
'table_id': table.get('id'),
|
|
54
|
+
'table_name': table.get('name'),
|
|
55
|
+
'table_category': table.get('category'),
|
|
56
|
+
'table_description': table.get('description')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for column in table.get('columns', []):
|
|
60
|
+
row = {
|
|
61
|
+
**table_info,
|
|
62
|
+
'column_id': column.get('id'),
|
|
63
|
+
'column_name': column.get('name'),
|
|
64
|
+
'column_description': column.get('description'),
|
|
65
|
+
'column_mandatory': column.get('mandatory'),
|
|
66
|
+
'column_type': column.get('type')
|
|
67
|
+
}
|
|
68
|
+
rows.append(row)
|
|
69
|
+
|
|
70
|
+
df = pd.DataFrame(rows)
|
|
71
|
+
|
|
72
|
+
# Validate against the metadata schema
|
|
73
|
+
valid_data, invalid_data = Functions.validate_data(df=df, schema=CustomTableMetadataSchema, debug=True)
|
|
74
|
+
|
|
75
|
+
return valid_data, invalid_data
|
brynq_sdk_bob-2.4.0/brynq_sdk_bob/people.py → brynq_sdk_bob-2.4.2/brynq_sdk_bob/payroll_history.py
RENAMED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
import pandas as pd
|
|
2
2
|
from brynq_sdk_functions import Functions
|
|
3
|
-
from .bank import Bank
|
|
4
|
-
from .employment import Employment
|
|
5
|
-
from .salaries import Salaries
|
|
6
3
|
from .schemas.people import PeopleSchema
|
|
7
|
-
from .work import Work
|
|
8
|
-
from .custom_tables import CustomTables
|
|
9
4
|
|
|
10
|
-
|
|
11
|
-
class People:
|
|
5
|
+
class History:
|
|
12
6
|
def __init__(self, bob):
|
|
13
7
|
self.bob = bob
|
|
14
|
-
self.salaries = Salaries(bob)
|
|
15
|
-
self.employment = Employment(bob)
|
|
16
|
-
self.bank = Bank(bob)
|
|
17
|
-
self.work = Work(bob)
|
|
18
|
-
self.custom_tables = CustomTables(bob)
|
|
19
8
|
self.schema = PeopleSchema
|
|
20
9
|
self.field_name_in_body, self.field_name_in_response, self.endpoint_to_response = self._init_fields()
|
|
21
10
|
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import re
|
|
3
|
+
from brynq_sdk_functions import Functions
|
|
4
|
+
from .bank import Bank
|
|
5
|
+
from .employment import Employment
|
|
6
|
+
from .salaries import Salaries
|
|
7
|
+
from .schemas.people import PeopleSchema
|
|
8
|
+
from .work import Work
|
|
9
|
+
from .custom_tables import CustomTables
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class People:
|
|
13
|
+
def __init__(self, bob):
|
|
14
|
+
self.bob = bob
|
|
15
|
+
self.salaries = Salaries(bob)
|
|
16
|
+
self.employment = Employment(bob)
|
|
17
|
+
self.bank = Bank(bob)
|
|
18
|
+
self.work = Work(bob)
|
|
19
|
+
self.custom_tables = CustomTables(bob)
|
|
20
|
+
self.schema = PeopleSchema
|
|
21
|
+
self.field_name_in_body, self.field_name_in_response, self.endpoint_to_response = self._init_fields()
|
|
22
|
+
|
|
23
|
+
# Define payroll information types and their configurations
|
|
24
|
+
self.payroll_types = {
|
|
25
|
+
'entitlement': {
|
|
26
|
+
'pattern': 'payroll.entitlement.',
|
|
27
|
+
'named_list_key': 'entitlementType'
|
|
28
|
+
},
|
|
29
|
+
'variable': {
|
|
30
|
+
'pattern': 'payroll.variable.',
|
|
31
|
+
'named_list_key': 'payrollVariableType'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
self.payroll_mappings = {}
|
|
35
|
+
|
|
36
|
+
def _add_payroll_fields_to_request(self, body_fields: list, response_fields: list, payroll_type: str) -> tuple[list, list]:
|
|
37
|
+
"""
|
|
38
|
+
Add payroll fields to request fields for a specific payroll type.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
body_fields: Current body fields
|
|
42
|
+
response_fields: Current response fields
|
|
43
|
+
payroll_type: Type of payroll information (e.g., 'entitlement', 'variable')
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
- body_fields: Updated body fields including payroll fields
|
|
47
|
+
- response_fields: Updated response fields including payroll fields
|
|
48
|
+
"""
|
|
49
|
+
if payroll_type not in self.payroll_types:
|
|
50
|
+
return body_fields, response_fields
|
|
51
|
+
|
|
52
|
+
pattern = self.payroll_types[payroll_type]['pattern']
|
|
53
|
+
payroll_fields_to_add = [field for field in self.field_name_in_body if pattern in field.lower()]
|
|
54
|
+
payroll_response_fields = [self.endpoint_to_response.get(field) for field in payroll_fields_to_add if field in self.endpoint_to_response]
|
|
55
|
+
|
|
56
|
+
for field in payroll_fields_to_add:
|
|
57
|
+
if field not in body_fields:
|
|
58
|
+
body_fields.append(field)
|
|
59
|
+
|
|
60
|
+
for field in payroll_response_fields:
|
|
61
|
+
if field and field not in response_fields:
|
|
62
|
+
response_fields.append(field)
|
|
63
|
+
|
|
64
|
+
return body_fields, response_fields
|
|
65
|
+
|
|
66
|
+
def _get_payroll_mapping(self, payroll_type: str) -> dict:
|
|
67
|
+
"""
|
|
68
|
+
Get mapping of payroll IDs to their names from the named-lists endpoint for a specific payroll type.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
payroll_type: Type of payroll information (e.g., 'entitlement', 'variable')
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dictionary mapping IDs to names
|
|
75
|
+
"""
|
|
76
|
+
if payroll_type not in self.payroll_types:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
# Check if we already have the mapping cached
|
|
80
|
+
if payroll_type in self.payroll_mappings:
|
|
81
|
+
return self.payroll_mappings[payroll_type]
|
|
82
|
+
|
|
83
|
+
resp = self.bob.session.get(
|
|
84
|
+
url=f"{self.bob.base_url}company/named-lists",
|
|
85
|
+
timeout=self.bob.timeout,
|
|
86
|
+
headers=self.bob.headers
|
|
87
|
+
)
|
|
88
|
+
named_lists = resp.json()
|
|
89
|
+
|
|
90
|
+
# Extract the mapping for this payroll type
|
|
91
|
+
mapping = {}
|
|
92
|
+
named_list_key = self.payroll_types[payroll_type]['named_list_key']
|
|
93
|
+
if named_list_key in named_lists:
|
|
94
|
+
for value in named_lists[named_list_key]['values']:
|
|
95
|
+
mapping[value['id']] = value['name']
|
|
96
|
+
|
|
97
|
+
# Cache the mapping
|
|
98
|
+
self.payroll_mappings[payroll_type] = mapping
|
|
99
|
+
return mapping
|
|
100
|
+
|
|
101
|
+
def _flatten_nested_payroll_data(self, df: pd.DataFrame, pattern: str) -> pd.DataFrame:
|
|
102
|
+
"""
|
|
103
|
+
Flatten nested JSON structures in payroll columns.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
df: DataFrame with potentially nested payroll data
|
|
107
|
+
pattern: Pattern to identify payroll columns (e.g., 'payroll.variable.')
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
DataFrame with flattened payroll columns
|
|
111
|
+
"""
|
|
112
|
+
# Identify payroll columns
|
|
113
|
+
payroll_columns = [col for col in df.columns if pattern in col.lower()]
|
|
114
|
+
|
|
115
|
+
if not payroll_columns:
|
|
116
|
+
return df
|
|
117
|
+
|
|
118
|
+
# Create a copy to avoid modifying the original
|
|
119
|
+
df_result = df.copy()
|
|
120
|
+
|
|
121
|
+
# Process each payroll column
|
|
122
|
+
for col in payroll_columns:
|
|
123
|
+
# Check if the column contains nested data
|
|
124
|
+
if df_result[col].notna().any():
|
|
125
|
+
# Get the first non-null value to check structure
|
|
126
|
+
sample_value = df_result[col].dropna().iloc[0] if not df_result[col].dropna().empty else None
|
|
127
|
+
|
|
128
|
+
if isinstance(sample_value, dict):
|
|
129
|
+
# Flatten nested structure
|
|
130
|
+
nested_df = pd.json_normalize(df_result[col].tolist())
|
|
131
|
+
|
|
132
|
+
# Rename columns to include the original column name as prefix
|
|
133
|
+
nested_df.columns = [f"{col}.{subcol}" for subcol in nested_df.columns]
|
|
134
|
+
|
|
135
|
+
# Drop the original column and add flattened columns
|
|
136
|
+
df_result = df_result.drop(columns=[col])
|
|
137
|
+
df_result = pd.concat([df_result, nested_df], axis=1)
|
|
138
|
+
|
|
139
|
+
return df_result
|
|
140
|
+
|
|
141
|
+
def _extract_payroll_columns(self, df: pd.DataFrame, payroll_type: str) -> pd.DataFrame:
|
|
142
|
+
"""
|
|
143
|
+
Extract payroll columns from DataFrame and rename them based on mapping.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
df: DataFrame containing all data including payroll columns
|
|
147
|
+
payroll_type: Type of payroll information (e.g., 'entitlement', 'variable')
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
DataFrame with only payroll columns (renamed if mapping available)
|
|
151
|
+
"""
|
|
152
|
+
if payroll_type not in self.payroll_types:
|
|
153
|
+
return pd.DataFrame(index=df.index)
|
|
154
|
+
|
|
155
|
+
# Get the pattern for this payroll type
|
|
156
|
+
pattern = self.payroll_types[payroll_type]['pattern']
|
|
157
|
+
|
|
158
|
+
# Identify all payroll columns for this type
|
|
159
|
+
payroll_columns = [col for col in df.columns if pattern in col.lower()]
|
|
160
|
+
|
|
161
|
+
if not payroll_columns:
|
|
162
|
+
# No payroll columns found, return empty DataFrame
|
|
163
|
+
return pd.DataFrame(index=df.index)
|
|
164
|
+
|
|
165
|
+
# Extract payroll columns
|
|
166
|
+
df_payroll = df[payroll_columns].copy()
|
|
167
|
+
|
|
168
|
+
# Get mapping for this payroll type
|
|
169
|
+
payroll_mapping = self._get_payroll_mapping(payroll_type)
|
|
170
|
+
|
|
171
|
+
# Rename payroll columns if mapping is available
|
|
172
|
+
rename_dict = {}
|
|
173
|
+
if payroll_mapping:
|
|
174
|
+
for col in df_payroll.columns:
|
|
175
|
+
# Extract the ID from the column name (any digits after the pattern)
|
|
176
|
+
# Use case-insensitive match but extract from original column name
|
|
177
|
+
pattern_regex = pattern.replace('.', r'\.') # Escape dots for regex
|
|
178
|
+
match = re.search(rf'({pattern_regex})(\d+)', col, re.IGNORECASE)
|
|
179
|
+
if match:
|
|
180
|
+
payroll_id = match.group(2)
|
|
181
|
+
if payroll_id in payroll_mapping:
|
|
182
|
+
# Replace only the first occurrence of the ID with the name
|
|
183
|
+
# This preserves any suffixes like .value, .currency, etc.
|
|
184
|
+
new_col_name = col[:match.start(2)] + payroll_mapping[payroll_id] + col[match.end(2):]
|
|
185
|
+
rename_dict[col] = new_col_name
|
|
186
|
+
|
|
187
|
+
# Apply the renaming
|
|
188
|
+
if rename_dict:
|
|
189
|
+
df_payroll = df_payroll.rename(columns=rename_dict)
|
|
190
|
+
|
|
191
|
+
return df_payroll
|
|
192
|
+
|
|
193
|
+
def get(self, additional_fields: list[str] = None, field_selection: list[str] = None, add_payroll_information: list[str] = None) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
194
|
+
"""
|
|
195
|
+
Get people from Bob
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
additional_fields (list[str]): Additional fields to get (not defined in the schema)
|
|
199
|
+
field_selection (list[str]): Fields to get (defined in the schema), if not provided, all fields are returned
|
|
200
|
+
add_payroll_information (list[str]): List of payroll information types to include (valid options: 'entitlement', 'variable')
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
valid_people (pd.DataFrame): Validated people data (with payroll information appended if requested)
|
|
204
|
+
invalid_people (pd.DataFrame): Invalid records
|
|
205
|
+
"""
|
|
206
|
+
#resp = self.bob.session.get(url=f"{self.bob.base_url}profiles", timeout=self.bob.timeout)
|
|
207
|
+
if additional_fields is None:
|
|
208
|
+
additional_fields = []
|
|
209
|
+
body_fields = list(set(self.field_name_in_body + additional_fields))
|
|
210
|
+
response_fields = list(set(self.field_name_in_response + additional_fields))
|
|
211
|
+
|
|
212
|
+
if field_selection:
|
|
213
|
+
body_fields = [field for field in body_fields if field in field_selection]
|
|
214
|
+
response_fields = [self.endpoint_to_response.get(field) for field in field_selection if field in self.endpoint_to_response]
|
|
215
|
+
|
|
216
|
+
# Add payroll fields to request if needed (before making the API call)
|
|
217
|
+
if add_payroll_information:
|
|
218
|
+
# Validate and filter payroll types
|
|
219
|
+
valid_payroll_types = [pt for pt in add_payroll_information if pt in self.payroll_types]
|
|
220
|
+
|
|
221
|
+
for payroll_type in valid_payroll_types:
|
|
222
|
+
body_fields, response_fields = self._add_payroll_fields_to_request(body_fields, response_fields, payroll_type)
|
|
223
|
+
|
|
224
|
+
# Bob sucks with default fields so you need to do a search call to retrieve additional fields.
|
|
225
|
+
resp_additional_fields = self.bob.session.post(url=f"{self.bob.base_url}people/search",
|
|
226
|
+
json={
|
|
227
|
+
"fields": body_fields,
|
|
228
|
+
"filters": []
|
|
229
|
+
},
|
|
230
|
+
timeout=self.bob.timeout)
|
|
231
|
+
df = pd.json_normalize(resp_additional_fields.json()['employees'])
|
|
232
|
+
|
|
233
|
+
# Validate payroll types if requested
|
|
234
|
+
valid_payroll_types = []
|
|
235
|
+
if add_payroll_information:
|
|
236
|
+
valid_payroll_types = [pt for pt in add_payroll_information if pt in self.payroll_types]
|
|
237
|
+
|
|
238
|
+
# Flatten nested data for each payroll type
|
|
239
|
+
for payroll_type in valid_payroll_types:
|
|
240
|
+
pattern = self.payroll_types[payroll_type]['pattern']
|
|
241
|
+
df = self._flatten_nested_payroll_data(df, pattern)
|
|
242
|
+
|
|
243
|
+
# Now filter columns - include original response_fields plus any flattened payroll columns
|
|
244
|
+
columns_to_keep = []
|
|
245
|
+
for col in df.columns:
|
|
246
|
+
# Keep if it's in response_fields
|
|
247
|
+
if col in response_fields:
|
|
248
|
+
columns_to_keep.append(col)
|
|
249
|
+
# Or if it's a payroll column (original or flattened)
|
|
250
|
+
elif valid_payroll_types:
|
|
251
|
+
for payroll_type in valid_payroll_types:
|
|
252
|
+
pattern = self.payroll_types[payroll_type]['pattern']
|
|
253
|
+
if pattern in col.lower():
|
|
254
|
+
columns_to_keep.append(col)
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
df = df[columns_to_keep]
|
|
258
|
+
|
|
259
|
+
# Map DataFrame columns (response fields) back to alias (endpoint) fields for validation
|
|
260
|
+
response_to_endpoint = {v: k for k, v in self.endpoint_to_response.items() if v}
|
|
261
|
+
df = df.rename(columns={col: response_to_endpoint[col] for col in df.columns if col in response_to_endpoint})
|
|
262
|
+
|
|
263
|
+
# Extract payroll information if requested
|
|
264
|
+
payroll_dataframes = {}
|
|
265
|
+
if valid_payroll_types:
|
|
266
|
+
for payroll_type in valid_payroll_types:
|
|
267
|
+
# Extract payroll columns into separate DataFrame
|
|
268
|
+
df_payroll = self._extract_payroll_columns(df, payroll_type)
|
|
269
|
+
if not df_payroll.empty:
|
|
270
|
+
payroll_dataframes[payroll_type] = df_payroll
|
|
271
|
+
|
|
272
|
+
# Remove payroll columns from main DataFrame for validation
|
|
273
|
+
pattern = self.payroll_types[payroll_type]['pattern']
|
|
274
|
+
payroll_columns = [col for col in df.columns if pattern in col.lower()]
|
|
275
|
+
if payroll_columns:
|
|
276
|
+
df = df.drop(columns=payroll_columns)
|
|
277
|
+
|
|
278
|
+
# Validate the data (without payroll information)
|
|
279
|
+
valid_people, invalid_people = Functions.validate_data(df=df, schema=PeopleSchema, debug=True)
|
|
280
|
+
|
|
281
|
+
# Append all payroll information to valid_people if they exist
|
|
282
|
+
if payroll_dataframes and not valid_people.empty:
|
|
283
|
+
for payroll_type, df_payroll in payroll_dataframes.items():
|
|
284
|
+
# Only include payroll data for valid rows
|
|
285
|
+
df_payroll_valid = df_payroll.loc[valid_people.index]
|
|
286
|
+
# Append payroll columns to valid_people
|
|
287
|
+
valid_people = pd.concat([valid_people, df_payroll_valid], axis=1)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# A lot of fields from Bob are returned with only ID's. Those fields should be mapped to names. Therefore, we need to get the mapping from the named-lists endpoint.
|
|
291
|
+
resp_named_lists = self.bob.session.get(url=f"{self.bob.base_url}company/named-lists", timeout=self.bob.timeout, headers=self.bob.headers)
|
|
292
|
+
named_lists = resp_named_lists.json()
|
|
293
|
+
|
|
294
|
+
# Transform named_lists to create id-to-value mappings for each field
|
|
295
|
+
named_lists = {key.split('.')[-1]: {item['id']: item['value'] for item in value['values']} for key, value in named_lists.items()}
|
|
296
|
+
|
|
297
|
+
for field in valid_people.columns:
|
|
298
|
+
# Fields in the response and in the named-list does have different building blocks (e.g. people.payroll.entitlement. or people.entitlement.). But they both end with the same last block
|
|
299
|
+
field_df = field.split('.')[-1].split('work_')[-1]
|
|
300
|
+
if field_df in named_lists.keys():
|
|
301
|
+
valid_people[field] = valid_people[field].map(named_lists[field_df])
|
|
302
|
+
|
|
303
|
+
return valid_people, invalid_people
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _init_fields(self) -> tuple[list[str], list[str], dict[str, str]]:
|
|
307
|
+
resp_fields = self.bob.session.get(
|
|
308
|
+
url=f"{self.bob.base_url}company/people/fields",
|
|
309
|
+
timeout=self.bob.timeout,
|
|
310
|
+
headers=self.bob.headers
|
|
311
|
+
)
|
|
312
|
+
fields = resp_fields.json()
|
|
313
|
+
field_name_in_body = [field.get('id') for field in fields]
|
|
314
|
+
# For all field names in field_name_in_body containing 'root.', add an alternative without 'root.' in addition to those fields
|
|
315
|
+
field_name_in_body = field_name_in_body
|
|
316
|
+
field_name_in_response = [field['jsonPath'] for field in fields]
|
|
317
|
+
endpoint_to_response = {field['id']: field['jsonPath'] for field in fields}
|
|
318
|
+
|
|
319
|
+
return field_name_in_body, field_name_in_response, endpoint_to_response
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _get_employee_id_to_person_id_mapping(self) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
324
|
+
employee_id_in_company = "work.employeeIdInCompany"
|
|
325
|
+
person_id = "root.id"
|
|
326
|
+
|
|
327
|
+
body_fields = [employee_id_in_company, person_id]
|
|
328
|
+
response_fields = [self.endpoint_to_response.get(field) for field in body_fields if field in self.endpoint_to_response]
|
|
329
|
+
|
|
330
|
+
resp_additional_fields = self.bob.session.post(url=f"{self.bob.base_url}people/search",
|
|
331
|
+
json={
|
|
332
|
+
"fields": body_fields,
|
|
333
|
+
"filters": []
|
|
334
|
+
},
|
|
335
|
+
timeout=self.bob.timeout)
|
|
336
|
+
df = pd.json_normalize(resp_additional_fields.json()['employees'])
|
|
337
|
+
df = df[[col for col in response_fields if col in df.columns]]
|
|
338
|
+
# Get the valid column names from PeopleSchema
|
|
339
|
+
valid_people, invalid_people = Functions.validate_data(df=df, schema=PeopleSchema, debug=True)
|
|
340
|
+
return valid_people, invalid_people
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pandera as pa
|
|
2
|
+
from pandera.typing import Series
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from brynq_sdk_functions import BrynQPanderaDataFrameModel
|
|
5
|
+
|
|
6
|
+
class CustomTableSchema(BrynQPanderaDataFrameModel):
|
|
7
|
+
id: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Custom Table ID", alias="id")
|
|
8
|
+
employee_id: Series[str] = pa.Field(coerce=True, description="Employee ID", alias="employee_id")
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
coerce = True
|
|
12
|
+
class CustomTableMetadataSchema(BrynQPanderaDataFrameModel):
|
|
13
|
+
# Table information
|
|
14
|
+
table_id: Series[str] = pa.Field(coerce=True, description="Table ID", alias="table_id")
|
|
15
|
+
table_name: Series[str] = pa.Field(coerce=True, description="Table Name", alias="table_name")
|
|
16
|
+
table_category: Series[str] = pa.Field(coerce=True, description="Table Category", alias="table_category")
|
|
17
|
+
table_description: Series[str] = pa.Field(coerce=True, nullable=True, description="Table Description", alias="table_description")
|
|
18
|
+
|
|
19
|
+
# Column information
|
|
20
|
+
column_id: Series[str] = pa.Field(coerce=True, description="Column ID", alias="column_id")
|
|
21
|
+
column_name: Series[str] = pa.Field(coerce=True, description="Column Name", alias="column_name")
|
|
22
|
+
column_description: Series[str] = pa.Field(coerce=True, nullable=True, description="Column Description", alias="column_description")
|
|
23
|
+
column_mandatory: Series[bool] = pa.Field(coerce=True, description="Is Column Mandatory", alias="column_mandatory")
|
|
24
|
+
column_type: Series[str] = pa.Field(coerce=True, description="Column Type", alias="column_type")
|
|
25
|
+
|
|
26
|
+
class Config:
|
|
27
|
+
coerce = True
|
|
@@ -26,7 +26,7 @@ class VariablePaymentSchema(BrynQPanderaDataFrameModel):
|
|
|
26
26
|
change_reason: Series[String] = pa.Field(nullable=True, coerce=True, description="Change Reason", alias="change.reason")
|
|
27
27
|
change_changed_by: Series[String] = pa.Field(nullable=True, coerce=True, description="Change Changed By", alias="change.changedBy")
|
|
28
28
|
change_changed_by_id: Series[pd.Int64Dtype] = pa.Field(nullable=True, coerce=True, description="Change Changed By ID", alias="change.changedById")
|
|
29
|
-
employee_id: Series[
|
|
29
|
+
employee_id: Series[String] = pa.Field(coerce=True, description="Employee ID", alias="employee_id") #set manually
|
|
30
30
|
class Config:
|
|
31
31
|
coerce = True
|
|
32
32
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pandera as pa
|
|
6
|
+
from pandera import Bool
|
|
7
|
+
from pandera.typing import Series, String, Float, DateTime
|
|
8
|
+
import pandera.extensions as extensions
|
|
9
|
+
from brynq_sdk_functions import BrynQPanderaDataFrameModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@extensions.register_check_method()
|
|
14
|
+
def check_list(x):
|
|
15
|
+
return isinstance(x, list)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PayrollHistorySchema(BrynQPanderaDataFrameModel):
|
|
19
|
+
id: Optional[Series[String]] = pa.Field(coerce=True, description="Person ID", alias="id")
|
|
20
|
+
display_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Display Name", alias="displayName")
|
|
21
|
+
company_id: Optional[Series[String]] = pa.Field(coerce=True, description="Company ID", alias="companyId")
|
|
22
|
+
|
|
23
|
+
class Config:
|
|
24
|
+
coerce = True
|
|
@@ -16,13 +16,13 @@ def check_list(x):
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class PeopleSchema(BrynQPanderaDataFrameModel):
|
|
19
|
-
id: Optional[Series[String]] = pa.Field(coerce=True, description="Person ID", alias="id")
|
|
19
|
+
id: Optional[Series[String]] = pa.Field(coerce=True, description="Person ID", alias="root.id")
|
|
20
20
|
display_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Display Name", alias="displayName")
|
|
21
21
|
company_id: Optional[Series[String]] = pa.Field(coerce=True, description="Company ID", alias="companyId")
|
|
22
|
-
email: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Email", alias="email")
|
|
22
|
+
email: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Email", alias="root.email")
|
|
23
23
|
home_mobile_phone: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Personal Mobile Phone", alias="home.mobilePhone")
|
|
24
|
-
surname: Optional[Series[String]] = pa.Field(coerce=True, description="Surname", alias="surname")
|
|
25
|
-
first_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="First Name", alias="firstName")
|
|
24
|
+
surname: Optional[Series[String]] = pa.Field(coerce=True, description="Surname", alias="root.surname")
|
|
25
|
+
first_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="First Name", alias="root.firstName")
|
|
26
26
|
full_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Full Name", alias="fullName")
|
|
27
27
|
personal_birth_date: Optional[Series[DateTime]] = pa.Field(coerce=True, nullable=True, description="Personal Birth Date", alias="personal.birthDate")
|
|
28
28
|
personal_pronouns: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Personal Pronouns", alias="personal.pronouns")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pandera as pa
|
|
2
2
|
from pandera.typing import Series, String, Float
|
|
3
|
+
from typing import Optional
|
|
3
4
|
import pandas as pd
|
|
4
5
|
from brynq_sdk_functions import BrynQPanderaDataFrameModel
|
|
5
6
|
|
|
@@ -15,15 +16,15 @@ class TimeOffSchema(BrynQPanderaDataFrameModel):
|
|
|
15
16
|
start_portion: Series[String] = pa.Field(coerce=True, description="Start Portion", alias="startPortion")
|
|
16
17
|
end_date: Series[String] = pa.Field(coerce=True, description="End Date", alias="endDate")
|
|
17
18
|
end_portion: Series[String] = pa.Field(coerce=True, description="End Portion", alias="endPortion")
|
|
18
|
-
day_portion: Series[String] = pa.Field(coerce=True, description="Day Portion", alias="dayPortion")
|
|
19
|
-
date: Series[String] = pa.Field(coerce=True, description="Date", alias="date")
|
|
20
|
-
hours_on_date: Series[Float] = pa.Field(coerce=True, description="Hours on Date", alias="hoursOnDate")
|
|
21
|
-
daily_hours: Series[Float] = pa.Field(coerce=True, description="Daily Hours", alias="dailyHours")
|
|
22
|
-
duration_unit: Series[String] = pa.Field(coerce=True, description="Duration Unit", alias="durationUnit")
|
|
23
|
-
total_duration: Series[Float] = pa.Field(coerce=True, description="Total Duration", alias="totalDuration")
|
|
24
|
-
total_cost: Series[Float] = pa.Field(coerce=True, description="Total Cost", alias="totalCost")
|
|
25
|
-
change_reason: Series[String] = pa.Field(nullable=True, coerce=True, description="Change Reason", alias="changeReason")
|
|
26
|
-
visibility: Series[String] = pa.Field(coerce=True, description="Visibility", alias="visibility")
|
|
19
|
+
day_portion: Optional[Series[String]] = pa.Field(coerce=True, description="Day Portion", alias="dayPortion")
|
|
20
|
+
date: Optional[Series[String]] = pa.Field(coerce=True, description="Date", alias="date")
|
|
21
|
+
hours_on_date: Optional[Series[Float]] = pa.Field(coerce=True, description="Hours on Date", alias="hoursOnDate")
|
|
22
|
+
daily_hours: Optional[Series[Float]] = pa.Field(coerce=True, description="Daily Hours", alias="dailyHours")
|
|
23
|
+
duration_unit: Optional[Series[String]] = pa.Field(coerce=True, description="Duration Unit", alias="durationUnit")
|
|
24
|
+
total_duration: Optional[Series[Float]] = pa.Field(coerce=True, description="Total Duration", alias="totalDuration")
|
|
25
|
+
total_cost: Optional[Series[Float]] = pa.Field(coerce=True, description="Total Cost", alias="totalCost")
|
|
26
|
+
change_reason: Optional[Series[String]] = pa.Field(nullable=True, coerce=True, description="Change Reason", alias="changeReason")
|
|
27
|
+
visibility: Optional[Series[String]] = pa.Field(coerce=True, description="Visibility", alias="visibility")
|
|
27
28
|
|
|
28
29
|
class Config:
|
|
29
30
|
coerce = True
|
|
@@ -11,7 +11,7 @@ class TimeOff:
|
|
|
11
11
|
self.schema = TimeOffSchema
|
|
12
12
|
self.balance_schema = TimeOffBalanceSchema
|
|
13
13
|
|
|
14
|
-
def get(self, since: datetime = None) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
14
|
+
def get(self, since: datetime = None, include_pending: bool = False) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
15
15
|
"""
|
|
16
16
|
Get time off requests
|
|
17
17
|
|
|
@@ -37,16 +37,12 @@ class TimeOff:
|
|
|
37
37
|
|
|
38
38
|
since = since.replace(tzinfo=timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
|
39
39
|
resp = self.bob.session.get(url=f"{self.bob.base_url}timeoff/requests/changes",
|
|
40
|
-
params={'since': since},
|
|
40
|
+
params={'since': since, 'includePending': 'true' if include_pending else 'false'},
|
|
41
41
|
timeout=self.bob.timeout)
|
|
42
42
|
resp.raise_for_status()
|
|
43
43
|
data = resp.json()['changes']
|
|
44
44
|
# data = self.bob.get_paginated_result(request)
|
|
45
|
-
df = pd.
|
|
46
|
-
data,
|
|
47
|
-
record_path='changes',
|
|
48
|
-
meta=['employeeId']
|
|
49
|
-
)
|
|
45
|
+
df = pd.DataFrame(data)
|
|
50
46
|
valid_timeoff, invalid_timeoff = Functions.validate_data(df=df, schema=self.schema, debug=True)
|
|
51
47
|
|
|
52
48
|
return valid_timeoff, invalid_timeoff
|
|
@@ -7,6 +7,7 @@ brynq_sdk_bob/documents.py
|
|
|
7
7
|
brynq_sdk_bob/employment.py
|
|
8
8
|
brynq_sdk_bob/named_lists.py
|
|
9
9
|
brynq_sdk_bob/payments.py
|
|
10
|
+
brynq_sdk_bob/payroll_history.py
|
|
10
11
|
brynq_sdk_bob/people.py
|
|
11
12
|
brynq_sdk_bob/salaries.py
|
|
12
13
|
brynq_sdk_bob/timeoff.py
|
|
@@ -23,6 +24,7 @@ brynq_sdk_bob/schemas/custom_tables.py
|
|
|
23
24
|
brynq_sdk_bob/schemas/employment.py
|
|
24
25
|
brynq_sdk_bob/schemas/named_lists.py
|
|
25
26
|
brynq_sdk_bob/schemas/payments.py
|
|
27
|
+
brynq_sdk_bob/schemas/payroll_history.py
|
|
26
28
|
brynq_sdk_bob/schemas/people.py
|
|
27
29
|
brynq_sdk_bob/schemas/salary.py
|
|
28
30
|
brynq_sdk_bob/schemas/timeoff.py
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
import pandas as pd
|
|
3
|
-
from brynq_sdk_functions import Functions
|
|
4
|
-
from .schemas.custom_tables import CustomTableSchema
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class CustomTables:
|
|
8
|
-
def __init__(self, bob):
|
|
9
|
-
self.bob = bob
|
|
10
|
-
self.schema = CustomTableSchema
|
|
11
|
-
|
|
12
|
-
def get(self, employee_id: str, custom_table_id: str) -> (pd.DataFrame, pd.DataFrame):
|
|
13
|
-
"""
|
|
14
|
-
Get custom table data for an employee
|
|
15
|
-
|
|
16
|
-
Args:
|
|
17
|
-
employee_id: The employee ID
|
|
18
|
-
custom_table_id: The custom table ID
|
|
19
|
-
|
|
20
|
-
Returns:
|
|
21
|
-
A tuple of (valid_data, invalid_data) as pandas DataFrames
|
|
22
|
-
"""
|
|
23
|
-
resp = self.bob.session.get(url=f"{self.bob.base_url}people/custom-tables/{employee_id}/{custom_table_id}")
|
|
24
|
-
resp.raise_for_status()
|
|
25
|
-
data = resp.json()
|
|
26
|
-
|
|
27
|
-
# Normalize the nested JSON response
|
|
28
|
-
df = pd.json_normalize(
|
|
29
|
-
data,
|
|
30
|
-
record_path=['values']
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
df['employee_id'] = employee_id
|
|
34
|
-
valid_data, invalid_data = Functions.validate_data(df=df, schema=self.schema, debug=True)
|
|
35
|
-
|
|
36
|
-
return valid_data, invalid_data
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import pandera as pa
|
|
2
|
-
from pandera.typing import Series
|
|
3
|
-
import pandas as pd
|
|
4
|
-
from brynq_sdk_functions import BrynQPanderaDataFrameModel
|
|
5
|
-
|
|
6
|
-
class CustomTableSchema(BrynQPanderaDataFrameModel):
|
|
7
|
-
id: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Custom Table ID", alias="id")
|
|
8
|
-
employee_id: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Employee ID", alias="employeeId")
|
|
9
|
-
|
|
10
|
-
class Config:
|
|
11
|
-
coerce = True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|