brynq-sdk-bob 2.5.2.dev0__tar.gz → 2.6.2.dev0__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.
Files changed (36) hide show
  1. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/PKG-INFO +1 -1
  2. brynq_sdk_bob-2.6.2.dev0/brynq_sdk_bob/__init__.py +77 -0
  3. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/bank.py +6 -1
  4. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/payments.py +46 -1
  5. brynq_sdk_bob-2.6.2.dev0/brynq_sdk_bob/people.py +100 -0
  6. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/salaries.py +9 -2
  7. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/bank.py +8 -7
  8. brynq_sdk_bob-2.6.2.dev0/brynq_sdk_bob/schemas/people.py +284 -0
  9. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/salary.py +1 -1
  10. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/PKG-INFO +1 -1
  11. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/setup.py +1 -1
  12. brynq_sdk_bob-2.5.2.dev0/brynq_sdk_bob/__init__.py +0 -349
  13. brynq_sdk_bob-2.5.2.dev0/brynq_sdk_bob/people.py +0 -343
  14. brynq_sdk_bob-2.5.2.dev0/brynq_sdk_bob/schemas/people.py +0 -100
  15. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/company.py +0 -0
  16. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/custom_tables.py +0 -0
  17. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/documents.py +0 -0
  18. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/employment.py +0 -0
  19. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/named_lists.py +0 -0
  20. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/payroll_history.py +0 -0
  21. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/__init__.py +0 -0
  22. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/custom_tables.py +0 -0
  23. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/employment.py +0 -0
  24. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/named_lists.py +0 -0
  25. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/payments.py +0 -0
  26. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/payroll_history.py +0 -0
  27. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/timeoff.py +0 -0
  28. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/schemas/work.py +0 -0
  29. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/timeoff.py +0 -0
  30. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob/work.py +0 -0
  31. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/SOURCES.txt +0 -0
  32. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/dependency_links.txt +0 -0
  33. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/not-zip-safe +0 -0
  34. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/requires.txt +0 -0
  35. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/brynq_sdk_bob.egg-info/top_level.txt +0 -0
  36. {brynq_sdk_bob-2.5.2.dev0 → brynq_sdk_bob-2.6.2.dev0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.0
2
2
  Name: brynq_sdk_bob
3
- Version: 2.5.2.dev0
3
+ Version: 2.6.2.dev0
4
4
  Summary: Bob wrapper from BrynQ
5
5
  Home-page: UNKNOWN
6
6
  Author: BrynQ
@@ -0,0 +1,77 @@
1
+ import base64
2
+ import re
3
+ from typing import Union, List, Optional, Literal
4
+ import pandas as pd
5
+ import requests
6
+ import os
7
+ from brynq_sdk_brynq import BrynQ
8
+ from brynq_sdk_functions import Functions
9
+ from .bank import Bank
10
+ from .company import Company
11
+ from .documents import CustomDocuments
12
+ from .employment import Employment
13
+ from .named_lists import NamedLists
14
+ from .payments import Payments
15
+ from .people import People
16
+ from .salaries import Salaries
17
+ from .timeoff import TimeOff
18
+ from .work import Work
19
+ from .custom_tables import CustomTables
20
+
21
+ class Bob(BrynQ):
22
+ def __init__(self, system_type: Optional[Literal['source', 'target']] = None, test_environment: bool = True, debug: bool = False, target_system: str = None):
23
+ super().__init__()
24
+ self.timeout = 3600
25
+ self.headers = self._get_request_headers(system_type)
26
+ if test_environment:
27
+ self.base_url = "https://api.sandbox.hibob.com/v1/"
28
+ else:
29
+ self.base_url = "https://api.hibob.com/v1/"
30
+ self.session = requests.Session()
31
+ self.session.headers.update(self.headers)
32
+ self.people = People(self)
33
+ self.salaries = Salaries(self)
34
+ self.work = Work(self)
35
+ self.bank = Bank(self)
36
+ self.employment = Employment(self)
37
+ self.payments = Payments(self)
38
+ self.time_off = TimeOff(self)
39
+ self.documents = CustomDocuments(self)
40
+ self.companies = Company(self)
41
+ self.named_lists = NamedLists(self)
42
+ self.custom_tables = CustomTables(self)
43
+ self.data_interface_id = os.getenv("DATA_INTERFACE_ID")
44
+ self.debug = debug
45
+
46
+ def _get_request_headers(self, system_type):
47
+ credentials = self.interfaces.credentials.get(system='bob', system_type=system_type)
48
+ if type(credentials) is list:
49
+ credentials = credentials[0]
50
+
51
+ print(credentials)
52
+ auth_token = base64.b64encode(f"{credentials.get('data').get('User ID')}:{credentials.get('data').get('API Token')}".encode()).decode('utf-8')
53
+ headers = {
54
+ "accept": "application/json",
55
+ "Authorization": f"Basic {auth_token}",
56
+ "Partner-Token": "001Vg00000A6FY6IAN"
57
+ }
58
+
59
+ return headers
60
+
61
+ def get_paginated_result(self, request: requests.Request) -> List:
62
+ has_next_page = True
63
+ result_data = []
64
+ while has_next_page:
65
+ prepped = request.prepare()
66
+ prepped.headers.update(self.session.headers)
67
+ resp = self.session.send(prepped, timeout=self.timeout)
68
+ resp.raise_for_status()
69
+ response_data = resp.json()
70
+ result_data += response_data['results']
71
+ next_cursor = response_data.get('response_metadata').get('next_cursor')
72
+ # If there is no next page, set has_next_page to False, we could use the falsy value of None but this is more readable
73
+ has_next_page = next_cursor is not None
74
+ if has_next_page:
75
+ request.params.update({"cursor": next_cursor})
76
+
77
+ return result_data
@@ -2,6 +2,8 @@ import pandas as pd
2
2
  from brynq_sdk_functions import Functions
3
3
  from .schemas.bank import BankSchema
4
4
 
5
+ import time
6
+ from tqdm import tqdm
5
7
 
6
8
  class Bank:
7
9
  def __init__(self, bob):
@@ -10,7 +12,7 @@ class Bank:
10
12
 
11
13
  def get(self, person_ids: pd.Series, field_selection: list[str] = []) -> (pd.DataFrame, pd.DataFrame):
12
14
  data = []
13
- for person_id in person_ids:
15
+ for person_id in tqdm(person_ids, desc="Fetching bank accounts"):
14
16
  resp = self.bob.session.get(url=f"{self.bob.base_url}people/{person_id}/bank-accounts", timeout=self.bob.timeout)
15
17
  resp.raise_for_status()
16
18
  temp_data = resp.json()['values']
@@ -19,6 +21,9 @@ class Bank:
19
21
  account['employee_id'] = person_id
20
22
  data += temp_data
21
23
 
24
+ # rate limit is 50 per minute
25
+ time.sleep(1.3)
26
+
22
27
  df = pd.DataFrame(data)
23
28
 
24
29
  valid_banks, invalid_banks = Functions.validate_data(df=df, schema=BankSchema, debug=True)
@@ -3,15 +3,49 @@ from typing import Optional, List
3
3
  from brynq_sdk_functions import Functions
4
4
  from .schemas.payments import VariablePaymentSchema, ActualPaymentsSchema
5
5
 
6
+ import time
7
+ from tqdm import tqdm
8
+
6
9
 
7
10
  class Payments:
8
11
  def __init__(self, bob):
9
12
  self.bob = bob
10
13
  self.schema = VariablePaymentSchema
11
14
 
15
+ def _apply_named_list_mappings(self, df: pd.DataFrame) -> pd.DataFrame:
16
+ """Apply named list ID-to-value mappings to dataframe columns."""
17
+ if df.empty:
18
+ return df
19
+
20
+ # Fetch named lists from Bob API
21
+ resp_named_lists = self.bob.session.get(
22
+ url=f"{self.bob.base_url}company/named-lists",
23
+ timeout=self.bob.timeout,
24
+ headers=self.bob.headers
25
+ )
26
+ named_lists = resp_named_lists.json()
27
+
28
+ # Transform named_lists to create id-to-value mappings for each field
29
+ named_lists = {
30
+ key.split('.')[-1]: {item['id']: item['value'] for item in value['values']}
31
+ for key, value in named_lists.items()
32
+ }
33
+
34
+ for field in df.columns:
35
+ # Fields in the response and in the named-list have different building blocks
36
+ # but they both end with the same last block
37
+ field_df = field.split('.')[-1].split('work_')[-1]
38
+ if field_df in named_lists.keys() and field_df not in ['site']:
39
+ mapping = named_lists[field_df]
40
+ df[field] = df[field].apply(
41
+ lambda v: [mapping.get(x, x) for x in v] if isinstance(v, list) else mapping.get(v, v)
42
+ )
43
+
44
+ return df
45
+
12
46
  def get(self, person_ids: List[str]) -> (pd.DataFrame, pd.DataFrame):
13
47
  df = pd.DataFrame()
14
- for person_id in person_ids:
48
+ for person_id in tqdm(person_ids, desc="Fetching variable payments"):
15
49
  resp = self.bob.session.get(url=f"{self.bob.base_url}people/{person_id}/variable", timeout=self.bob.timeout)
16
50
  resp.raise_for_status()
17
51
  data = resp.json()
@@ -20,7 +54,15 @@ class Payments:
20
54
  record_path='values'
21
55
  )])
22
56
  df['employee_id'] = person_id
57
+
58
+ # Rate limit is 50 per minute
59
+ time.sleep(1.3)
60
+
23
61
  df = df.reset_index(drop=True)
62
+
63
+ # Apply named list mappings
64
+ df = self._apply_named_list_mappings(df)
65
+
24
66
  valid_payments, invalid_payments = Functions.validate_data(df=df, schema=self.schema, debug=True)
25
67
  return valid_payments, invalid_payments
26
68
 
@@ -107,6 +149,9 @@ class Payments:
107
149
 
108
150
  df = pd.json_normalize(all_results)
109
151
 
152
+ # Apply named list mappings
153
+ df = self._apply_named_list_mappings(df)
154
+
110
155
  valid_payments, invalid_payments = Functions.validate_data(
111
156
  df=df,
112
157
  schema=ActualPaymentsSchema,
@@ -0,0 +1,100 @@
1
+ import pandas as pd
2
+ from typing import Optional, List
3
+ from brynq_sdk_functions import Functions
4
+ from brynq_sdk_functions import BrynQPanderaDataFrameModel
5
+ from .bank import Bank
6
+ from .employment import Employment
7
+ from .salaries import Salaries
8
+ from .schemas.people import PeopleSchema
9
+ from .work import Work
10
+ from .custom_tables import CustomTables
11
+
12
+
13
+ class People:
14
+ def __init__(self, bob):
15
+ self.bob = bob
16
+ self.salaries = Salaries(bob)
17
+ self.employment = Employment(bob)
18
+ self.bank = Bank(bob)
19
+ self.work = Work(bob)
20
+ self.custom_tables = CustomTables(bob)
21
+ self.schema = PeopleSchema
22
+
23
+
24
+ # Build API fields using column metadata if present (api_field), otherwise use the column (alias) name
25
+ def __build_api_fields(self, schema_model: BrynQPanderaDataFrameModel) -> list[str]:
26
+ schema = schema_model.to_schema()
27
+ return [
28
+ ((getattr(col, "metadata", None) or {}).get("api_field")) or col_name
29
+ for col_name, col in schema.columns.items()
30
+ ]
31
+
32
+ def get(self, schema_custom_fields: Optional[BrynQPanderaDataFrameModel] = None, employee_ids: Optional[List[str]] = None) -> pd.DataFrame:
33
+ core_fields = self.__build_api_fields(PeopleSchema)
34
+ custom_fields = self.__build_api_fields(schema_custom_fields) if schema_custom_fields is not None else []
35
+ fields = core_fields + custom_fields
36
+
37
+ # Build filters based on employee_ids if provided
38
+ filters = []
39
+ if employee_ids is not None:
40
+ filters = [
41
+ {
42
+ "fieldPath": "root.id",
43
+ "operator": "equals",
44
+ "values": employee_ids
45
+ }
46
+ ]
47
+
48
+ resp = self.bob.session.post(url=f"{self.bob.base_url}people/search",
49
+ json={
50
+ "fields": fields,
51
+ "filters": filters
52
+ #"humanReadable": "REPLACE"
53
+ },
54
+ timeout=self.bob.timeout)
55
+ resp.raise_for_status()
56
+ df = pd.json_normalize(resp.json()['employees'])
57
+ df = df.loc[:, ~df.columns.str.contains('value')]
58
+
59
+ # Normalize separators in incoming data: convert '/' to '.' to match schema aliases
60
+ df.columns = df.columns.str.replace('/', '.', regex=False)
61
+
62
+ # 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.
63
+ resp_named_lists = self.bob.session.get(url=f"{self.bob.base_url}company/named-lists", timeout=self.bob.timeout, headers=self.bob.headers)
64
+ named_lists = resp_named_lists.json()
65
+ # save json to file
66
+ # import json
67
+ # with open('named_lists.json', 'w') as f:
68
+ # json.dump(named_lists, f, indent=4)
69
+
70
+ # Transform named_lists to create id-to-value mappings for each field
71
+ named_lists = {key.split('.')[-1]: {item['id']: item['value'] for item in value['values']} for key, value in named_lists.items()}
72
+
73
+ deviating_named_list_cols_mapping = {
74
+ 'payroll.employment.type': 'payrollEmploymentType',
75
+ 'home.familyStatus': 'familystatus',
76
+ 'personal.nationality': 'nationalities',
77
+ }
78
+
79
+ for field in df.columns:
80
+ # 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
81
+ field_df = field.split('.')[-1].split('work_')[-1]
82
+
83
+ # Check if this field has a deviating mapping
84
+ named_list_key = deviating_named_list_cols_mapping.get(field, field_df)
85
+
86
+ if named_list_key in named_lists.keys() and named_list_key not in ['site']:
87
+ mapping = named_lists[named_list_key]
88
+ df[field] = df[field].apply(
89
+ lambda v: [mapping.get(x, x) for x in v] if isinstance(v, list) else mapping.get(v, v)
90
+ )
91
+
92
+ if schema_custom_fields is not None:
93
+ valid_people, invalid_people_custom = Functions.validate_data(df=df, schema=schema_custom_fields, debug=True)
94
+ else:
95
+ valid_people = df
96
+ invalid_people_custom = pd.DataFrame()
97
+
98
+ valid_people, invalid_people = Functions.validate_data(df=valid_people, schema=PeopleSchema, debug=True)
99
+
100
+ return valid_people, pd.concat([invalid_people, invalid_people_custom])
@@ -1,5 +1,6 @@
1
1
  import pandas as pd
2
2
  import requests
3
+ from typing import Optional, List
3
4
  from brynq_sdk_functions import Functions
4
5
  from .schemas.salary import SalarySchema, SalaryCreateSchema
5
6
 
@@ -9,10 +10,16 @@ class Salaries:
9
10
  self.bob = bob
10
11
  self.schema = SalarySchema
11
12
 
12
- def get(self) -> tuple[pd.DataFrame, pd.DataFrame]:
13
+ def get(self, employee_ids: Optional[List[str]] = None) -> tuple[pd.DataFrame, pd.DataFrame]:
14
+ params = {"limit": 100}
15
+
16
+ # Add employeeIds filter if provided
17
+ if employee_ids is not None:
18
+ params["employeeIds"] = ",".join(employee_ids)
19
+
13
20
  request = requests.Request(method='GET',
14
21
  url=f"{self.bob.base_url}bulk/people/salaries",
15
- params={"limit": 100})
22
+ params=params)
16
23
  data = self.bob.get_paginated_result(request)
17
24
  df = pd.json_normalize(
18
25
  data,
@@ -1,4 +1,5 @@
1
1
  import pandera as pa
2
+ from typing import Optional
2
3
  from pandera.typing import Series, String
3
4
  import pandas as pd
4
5
  from brynq_sdk_functions import BrynQPanderaDataFrameModel
@@ -6,19 +7,19 @@ from brynq_sdk_functions import BrynQPanderaDataFrameModel
6
7
 
7
8
  class BankSchema(BrynQPanderaDataFrameModel):
8
9
  id: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Bank ID", alias="id")
9
- employee_id: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Employee ID", alias="employeeId")
10
- amount: Series[pd.Int64Dtype] = pa.Field(coerce=True, description="Amount", alias="amount")
11
- allocation: Series[String] = pa.Field(coerce=True, nullable=True, description="Allocation", alias="allocation")
12
- branch_address: Series[String] = pa.Field(coerce=True, nullable=True, description="Branch Address", alias="branchAddress")
10
+ employee_id: Series[String] = pa.Field(coerce=True, description="Employee ID", alias="employee_id")
11
+ amount: Optional[Series[pd.Int64Dtype]] = pa.Field(coerce=True, description="Amount", alias="amount")
12
+ allocation: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Allocation", alias="allocation")
13
+ branch_address: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Branch Address", alias="branchAddress")
13
14
  bank_name: Series[String] = pa.Field(coerce=True, nullable=True, description="Bank Name", alias="bankName")
14
15
  account_number: Series[String] = pa.Field(coerce=True, nullable=True, description="Account Number", alias="accountNumber")
15
- routing_number: Series[String] = pa.Field(coerce=True, nullable=True, description="Routing Number", alias="routingNumber")
16
+ routing_number: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Routing Number", alias="routingNumber")
16
17
  bank_account_type: Series[String] = pa.Field(coerce=True, nullable=True, description="Bank Account Type", alias="bankAccountType")
17
- bic_or_swift: Series[String] = pa.Field(coerce=True, nullable=True, description="BIC or SWIFT", alias="bicOrSwift")
18
+ bic_or_swift: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="BIC or SWIFT", alias="bicOrSwift")
18
19
  changed_by: Series[String] = pa.Field(coerce=True, nullable=True, description="Changed By", alias="changedBy")
19
20
  iban: Series[String] = pa.Field(coerce=True, description="IBAN", alias="iban")
20
21
  account_nickname: Series[String] = pa.Field(coerce=True, nullable=True, description="Account Nickname", alias="accountNickname")
21
- use_for_bonus: Series[pd.BooleanDtype] = pa.Field(coerce=True, nullable=True, description="Use for Bonus", alias="useForBonus")
22
+ use_for_bonus: Optional[Series[pd.BooleanDtype]] = pa.Field(coerce=True, nullable=True, description="Use for Bonus", alias="useForBonus")
22
23
 
23
24
  class Config:
24
25
  coerce = True