brynq-sdk-acerta 1.1.1__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 (34) hide show
  1. brynq_sdk_acerta/__init__.py +14 -0
  2. brynq_sdk_acerta/acerta.py +118 -0
  3. brynq_sdk_acerta/addresses.py +99 -0
  4. brynq_sdk_acerta/agreements.py +426 -0
  5. brynq_sdk_acerta/bank_accounts.py +90 -0
  6. brynq_sdk_acerta/code_lists.py +264 -0
  7. brynq_sdk_acerta/company_cars.py +135 -0
  8. brynq_sdk_acerta/contact_information.py +79 -0
  9. brynq_sdk_acerta/cost_centers.py +94 -0
  10. brynq_sdk_acerta/employees.py +121 -0
  11. brynq_sdk_acerta/employees_additional_information.py +87 -0
  12. brynq_sdk_acerta/employer.py +179 -0
  13. brynq_sdk_acerta/family_members.py +99 -0
  14. brynq_sdk_acerta/family_situation.py +99 -0
  15. brynq_sdk_acerta/inservice.py +99 -0
  16. brynq_sdk_acerta/salaries.py +74 -0
  17. brynq_sdk_acerta/schemas/__init__.py +135 -0
  18. brynq_sdk_acerta/schemas/address.py +80 -0
  19. brynq_sdk_acerta/schemas/agreement.py +982 -0
  20. brynq_sdk_acerta/schemas/bank_account.py +87 -0
  21. brynq_sdk_acerta/schemas/company_car.py +124 -0
  22. brynq_sdk_acerta/schemas/contact_information.py +83 -0
  23. brynq_sdk_acerta/schemas/cost_center.py +82 -0
  24. brynq_sdk_acerta/schemas/employee.py +406 -0
  25. brynq_sdk_acerta/schemas/employer.py +71 -0
  26. brynq_sdk_acerta/schemas/family.py +220 -0
  27. brynq_sdk_acerta/schemas/in_service.py +243 -0
  28. brynq_sdk_acerta/schemas/in_service_config.py +28 -0
  29. brynq_sdk_acerta/schemas/planning.py +37 -0
  30. brynq_sdk_acerta/schemas/salaries.py +84 -0
  31. brynq_sdk_acerta-1.1.1.dist-info/METADATA +21 -0
  32. brynq_sdk_acerta-1.1.1.dist-info/RECORD +34 -0
  33. brynq_sdk_acerta-1.1.1.dist-info/WHEEL +5 -0
  34. brynq_sdk_acerta-1.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ """
2
+ Brynq SDK for Acerta API integration
3
+ """
4
+
5
+ from .acerta import Acerta
6
+ from .agreements import Agreements
7
+ from .employees import Employees
8
+ from .code_lists import CodeLists
9
+ from .employer import Employer
10
+ from .employees_additional_information import EmployeesAdditionalInformation
11
+ from .inservice import InService
12
+ from .company_cars import CompanyCars
13
+
14
+ __all__ = ['Acerta', 'CodeLists', 'Agreements', 'Employees', 'Employer', 'InService', 'EmployeesAdditionalInformation', 'CompanyCars']
@@ -0,0 +1,118 @@
1
+ from typing_extensions import List
2
+ from typing import Literal, Optional, Union
3
+ import time
4
+
5
+ from .salaries import Salaries
6
+ from .cost_centers import CostCenters
7
+ from brynq_sdk_brynq import BrynQ
8
+ from .code_lists import CodeLists
9
+ from .agreements import Agreements
10
+ from .inservice import InService
11
+ from .company_cars import CompanyCars
12
+ from .employees import Employees
13
+ from .employer import Employer
14
+ from requests_oauthlib import OAuth2Session
15
+ from oauthlib.oauth2 import BackendApplicationClient
16
+
17
+
18
+ class Acerta(BrynQ):
19
+ """
20
+ Base class for interacting with the Acerta API.
21
+ """
22
+
23
+ # Default timeout in seconds for all requests
24
+ TIMEOUT = 30
25
+
26
+ def __init__(self, employers: Union[str, List], system_type: Optional[Literal['source', 'target']] = None, test_environment: bool = True, debug: bool = False):
27
+ """
28
+ Initialize the Acerta API client.
29
+
30
+ Args:
31
+ system_type (str): System type ('source' or 'target')
32
+ debug (bool): Debug flag - if True uses test environment, if False uses production
33
+ """
34
+ super().__init__()
35
+
36
+ # Compute environment-specific prefix once and reuse
37
+ env_prefix = "a-" if test_environment else ""
38
+ self.base_url = f"https://{env_prefix}api.acerta.be"
39
+
40
+ # Extract credentials and configure OAuth2 session with automatic token renewal
41
+ credentials = self.interfaces.credentials.get(
42
+ system="acerta-acceptance",
43
+ system_type=system_type,
44
+ )
45
+ data = credentials.get("data", {})
46
+ client_id = data.get("client_id")
47
+ client_secret = data.get("client_secret")
48
+
49
+ # Token endpoint (match test/prod like base_url)
50
+ token_url = f"https://{env_prefix}signin.acerta.be/am/oauth2/access_token"
51
+
52
+ # Store client credentials for reuse
53
+ self._client_id = client_id
54
+ self._client_secret = client_secret
55
+ self._token_url = token_url
56
+
57
+ # Create OAuth2 session using client credentials (Backend Application flow)
58
+ client = BackendApplicationClient(client_id=self._client_id)
59
+ oauth_session = OAuth2Session(client=client)
60
+
61
+ # Fetch initial token
62
+ token = oauth_session.fetch_token(
63
+ token_url=self._token_url,
64
+ client_id=self._client_id,
65
+ client_secret=self._client_secret,
66
+ include_client_id=True,
67
+ )
68
+
69
+ # Keep access_token attribute for backward compatibility
70
+ self.access_token = token.get("access_token")
71
+
72
+ # Attach default headers; Authorization is managed by OAuth2Session
73
+ oauth_session.headers.update({
74
+ "Accept": "application/json",
75
+ "Content-Type": "application/json",
76
+ })
77
+
78
+ # Ensure token is valid before each request (client_credentials has no refresh_token)
79
+ self._orig_request = oauth_session.request
80
+ oauth_session.request = self._request_with_pre_expiry # type: ignore[assignment]
81
+
82
+ # Use the OAuth2 session for all requests
83
+ self.session = oauth_session
84
+ self.session.timeout = self.TIMEOUT # type: ignore[attr-defined]
85
+ self._employer_ids = employers if isinstance(employers, List) else [employers]
86
+ self._employee_ids = set()
87
+ self._agreement_ids = set()
88
+
89
+ # Set debug mode
90
+ self.debug = debug
91
+
92
+ # Initialize resource classes
93
+ self.agreements = Agreements(self)
94
+ self.inservice = InService(self)
95
+ self.cost_centers = CostCenters(self)
96
+ self.code_lists = CodeLists(self)
97
+ self.employees = Employees(self)
98
+ self.employers = Employer(self)
99
+ self.salaries = Salaries(self)
100
+ self.company_cars = CompanyCars(self)
101
+
102
+ def _ensure_valid_token(self):
103
+ """Ensure the OAuth token exists and is not about to expire."""
104
+ tok = getattr(self.session, "token", {}) or {}
105
+ expires_at = tok.get("expires_at")
106
+ # Refresh if missing or expiring within 30 seconds
107
+ if not expires_at or (expires_at - time.time()) < 30:
108
+ new_token = self.session.fetch_token(
109
+ token_url=self._token_url,
110
+ client_id=self._client_id,
111
+ client_secret=self._client_secret,
112
+ include_client_id=True,
113
+ )
114
+ self.access_token = new_token.get("access_token")
115
+
116
+ def _request_with_pre_expiry(self, method, url, **kwargs):
117
+ self._ensure_valid_token()
118
+ return self._orig_request(method, url, **kwargs)
@@ -0,0 +1,99 @@
1
+ from typing import Tuple, Dict, Any
2
+ import pandas as pd
3
+ import requests
4
+ from brynq_sdk_functions import Functions
5
+ from .schemas.address import AddressGet, AddressUpdate
6
+ from typing import TYPE_CHECKING
7
+ if TYPE_CHECKING:
8
+ from .acerta import Acerta
9
+
10
+ class Addresses:
11
+ """Resource class for Employee Address endpoints"""
12
+
13
+ def __init__(self, acerta):
14
+ self.acerta: Acerta = acerta
15
+ self.base_uri = "v3/employee-data-management/v3/employees"
16
+
17
+ def get(self, from_date: str = "1900-01-01", until_date: str = "9999-12-31") -> Tuple[pd.DataFrame, pd.DataFrame]:
18
+ """
19
+ GET /employee-data-management/v3/employees/{employeeId}/addresses - Employee Addresses
20
+
21
+ Retrieve employee address history within a specified time window for all cached employees.
22
+ Returns both official and correspondence addresses with their validity periods.
23
+
24
+ Args:
25
+ from_date: Start date of the time window (default: "1900-01-01")
26
+ until_date: End date of the time window (default: "9999-12-31")
27
+
28
+ Returns:
29
+ Tuple[pd.DataFrame, pd.DataFrame]: (valid_df, invalid_df) after validation
30
+
31
+ Raises:
32
+ RuntimeError: If the retrieval fails
33
+ """
34
+ params = {
35
+ "fromDate": from_date,
36
+ "untilDate": until_date
37
+ }
38
+
39
+ if not self.acerta._employee_ids:
40
+ # Auto-warm cache by fetching agreements (populates employee IDs)
41
+ self.acerta.agreements.get()
42
+
43
+ all_frames = []
44
+ for employee_id in self.acerta._employee_ids:
45
+ response = self.acerta.session.get(
46
+ url=f"{self.acerta.base_url}/{self.base_uri}/{employee_id}/addresses",
47
+ params=params,
48
+ timeout=self.acerta.TIMEOUT,
49
+ )
50
+ response.raise_for_status()
51
+ content = response.json()
52
+ df = pd.json_normalize(
53
+ content,
54
+ record_path=['addressSegments'],
55
+ meta=['employeeId'],
56
+ sep='.'
57
+ )
58
+ if 'externalReferences' in df.columns:
59
+ df = df.drop(columns=['externalReferences'])
60
+ all_frames.append(df)
61
+
62
+ combined = pd.concat(all_frames, ignore_index=True) if all_frames else pd.DataFrame()
63
+
64
+ valid_data, invalid_data = Functions.validate_data(combined, AddressGet)
65
+
66
+ return valid_data, invalid_data
67
+
68
+ def update(self, employee_id: str, data: Dict[str, Any]) -> requests.Response:
69
+ """
70
+ PATCH /employee-data-management/v3/employees/{employeeId}/addresses - Employee Address
71
+
72
+ Update employee address information including official address and optional
73
+ correspondence address. Addresses are historical data with validity dates.
74
+
75
+ Args:
76
+ employee_id: Unique identifier of an employee
77
+ data: Flat dictionary with address data
78
+
79
+ Returns:
80
+ requests.Response: Raw response object
81
+
82
+ Raises:
83
+ RuntimeError: If the address update fails
84
+ """
85
+ # Convert flat data to nested using Functions.flat_to_nested_with_prefix
86
+ nested_data = Functions.flat_to_nested_with_prefix(data, AddressUpdate)
87
+
88
+ # Validate the nested data
89
+ validated_data = AddressUpdate(**nested_data)
90
+
91
+ # Make API request
92
+ response = self.acerta.session.patch(
93
+ url=f"{self.acerta.base_url}/{self.base_uri}/{employee_id}/addresses",
94
+ json=validated_data.model_dump(by_alias=True, exclude_none=True),
95
+ timeout=self.acerta.TIMEOUT,
96
+ )
97
+ response.raise_for_status()
98
+
99
+ return response
@@ -0,0 +1,426 @@
1
+ from typing import Dict, Any, Optional, Tuple
2
+ import requests
3
+ import pandas as pd
4
+ from .schemas.agreement import (
5
+ AgreementGet,
6
+ PatchAgreementRequest,
7
+ AgreementBasicInformationGet,
8
+ AgreementEmploymentsGet,
9
+ AgreementWorkingTimeGet,
10
+ AgreementCommutingGet,
11
+ AgreementCustomFieldsGet,
12
+ AgreementCostCenterAllocationGet,
13
+ )
14
+ from .schemas.in_service_config import JointCommitteeGet, FunctionGet
15
+ from brynq_sdk_functions import Functions
16
+ from typing import TYPE_CHECKING
17
+ if TYPE_CHECKING:
18
+ from .acerta import Acerta
19
+
20
+ class Agreements:
21
+ """Resource class for Agreement endpoints"""
22
+
23
+ def __init__(self, acerta):
24
+ self.acerta: Acerta = acerta
25
+ self.base_uri = "agreement-data-management/v3/agreements"
26
+ # We cache agreements to avoid duplicate requests. Agreements always need to be fetched in order to get employee_ids.
27
+ # For ease of use, we do that in the background, so you can retrieve employees without having to fetch agreements first.
28
+ # To avoid retrieving agreements twice, we cache them so we can return them if they are already cached.
29
+ self._cached_agreements = None
30
+
31
+ def get_joint_committees(self, employer_id: Optional[str] = None, in_service_type: Optional[str] = None, accept_language: str = "en") -> Tuple[pd.DataFrame, pd.DataFrame]:
32
+ """
33
+ GET /employee-in-service-request/v1/employers/{employerId}/joint-committees
34
+ """
35
+ employers = [employer_id] if employer_id else self.acerta._employer_ids
36
+ all_rows = []
37
+ for emp_id in employers:
38
+ params = {"Accept-Language": accept_language}
39
+ if in_service_type:
40
+ params["inServiceType"] = in_service_type
41
+ response = self.acerta.session.get(
42
+ url=f"{self.acerta.base_url}/employee-in-service-request/v1/employers/{emp_id}/joint-committees",
43
+ params=params,
44
+ timeout=self.acerta.TIMEOUT,
45
+ )
46
+ response.raise_for_status()
47
+ raw = response.json()
48
+ items = raw.get("_embedded", {}).get("jointCommittees", [])
49
+ if items:
50
+ df = pd.json_normalize(items)
51
+ df["employer_id"] = emp_id
52
+ all_rows.append(df)
53
+ combined = pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
54
+ return Functions.validate_data(combined, JointCommitteeGet)
55
+
56
+ def get_functions(self, employer_id: Optional[str] = None, accept_language: str = "en") -> Tuple[pd.DataFrame, pd.DataFrame]:
57
+ """
58
+ GET /employee-in-service-request/v1/employers/{employerId}/functions
59
+ """
60
+ employers = [employer_id] if employer_id else self.acerta._employer_ids
61
+ all_rows = []
62
+ for emp_id in employers:
63
+ params = {"Accept-Language": accept_language}
64
+ response = self.acerta.session.get(
65
+ url=f"{self.acerta.base_url}/employee-in-service-request/v1/employers/{emp_id}/functions",
66
+ params=params,
67
+ timeout=self.acerta.TIMEOUT,
68
+ )
69
+ response.raise_for_status()
70
+ raw = response.json()
71
+ items = raw.get("_embedded", {}).get("functions", [])
72
+ if items:
73
+ df = pd.json_normalize(items)
74
+ df["employer_id"] = emp_id
75
+ all_rows.append(df)
76
+ combined = pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
77
+ return Functions.validate_data(combined, FunctionGet)
78
+
79
+
80
+ def get(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
81
+ """
82
+ GET /v3/agreements - Agreements
83
+
84
+ Retrieve agreements for all configured employer IDs. Returns agreement details including
85
+ agreement ID, employer information, legal entity, and agreement type.
86
+
87
+ Behavior:
88
+ - Aggregates results across `self.acerta._employer_ids`
89
+ - Validates and splits into valid/invalid DataFrames
90
+ - Updates `self.acerta._employee_ids` and `_agreement_ids`
91
+ - Caches results on first call within this instance
92
+
93
+ Returns:
94
+ Tuple[pd.DataFrame, pd.DataFrame]: (valid_df, invalid_df)
95
+
96
+ Raises:
97
+ Exception: If retrieval or validation fails
98
+ """
99
+ if self._cached_agreements is None:
100
+ self._cached_agreements = self._get_agreements()
101
+ return self._cached_agreements
102
+
103
+ def _get_agreements(self):
104
+ all_dfs = []
105
+ for employer in self.acerta._employer_ids:
106
+ page = 0
107
+ total_pages = 1
108
+
109
+ while page < total_pages:
110
+ params = {k: v for k, v in {
111
+ "employerId": employer,
112
+ "page": page,
113
+ "size": 200,
114
+ }.items() if v is not None}
115
+
116
+ response = self.acerta.session.get(
117
+ url=f"{self.acerta.base_url}/{self.base_uri}",
118
+ params=params,
119
+ timeout=self.acerta.TIMEOUT,
120
+ )
121
+ response.raise_for_status()
122
+
123
+ agreements_data = response.json().get("_embedded", {}).get("agreements", [])
124
+ if agreements_data:
125
+ df = pd.json_normalize(agreements_data, sep=".")
126
+ all_dfs.append(df)
127
+
128
+ page_info = response.json().get("page") or {}
129
+ total_pages = max(total_pages, page_info.get("totalPages", total_pages))
130
+ page += 1
131
+
132
+ agreements = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
133
+ valid_agreements, invalid_agreements = Functions.validate_data(agreements, AgreementGet, debug=self.acerta.debug)
134
+
135
+ if not valid_agreements.empty:
136
+ self.acerta._employee_ids.update(valid_agreements["employee_id"].dropna().unique())
137
+ self.acerta._agreement_ids.update(valid_agreements["agreement_id"].dropna().unique())
138
+
139
+ return valid_agreements, invalid_agreements
140
+
141
+ def get_by_id(self, agreement_id: str, accept_language: str = "en") -> Tuple[pd.DataFrame, pd.DataFrame]:
142
+ """GET /v3/agreements/{agreementId}"""
143
+ headers = {"Accept-Language": accept_language} if accept_language else None
144
+ request_headers = self.acerta.session.headers.copy()
145
+ if headers:
146
+ request_headers.update(headers)
147
+ response = self.acerta.session.get(
148
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agreement_id}",
149
+ headers=request_headers,
150
+ timeout=self.acerta.TIMEOUT,
151
+ )
152
+ response.raise_for_status()
153
+ raw = response.json()
154
+ if isinstance(raw, dict):
155
+ df = pd.json_normalize(raw, sep=".")
156
+ else:
157
+ df = pd.json_normalize([raw], sep=".")
158
+ return df, pd.DataFrame()
159
+
160
+ def update(self, agreement_id: str, data: Dict[str, Any], accept_language: str = "en") -> requests.Response:
161
+ """PATCH /v3/agreements/{agreementId}"""
162
+ nested_data = Functions.flat_to_nested_with_prefix(data, PatchAgreementRequest)
163
+ validated = PatchAgreementRequest(**nested_data)
164
+ headers = {"Accept-Language": accept_language} if accept_language else None
165
+ request_headers = self.acerta.session.headers.copy()
166
+ if headers:
167
+ request_headers.update(headers)
168
+ response = self.acerta.session.patch(
169
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agreement_id}",
170
+ json=validated.model_dump(by_alias=True, exclude_none=True),
171
+ headers=request_headers,
172
+ timeout=self.acerta.TIMEOUT,
173
+ )
174
+ response.raise_for_status()
175
+ return response
176
+
177
+ def get_basic_information(
178
+ self,
179
+ agreement_id: Optional[str] = None,
180
+ from_date: Optional[str] = None,
181
+ until_date: Optional[str] = None,
182
+ full_segments_only: Optional[bool] = None,
183
+ accept_language: str = "en",
184
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
185
+ """GET /v3/agreements/{agreementId}/basic-information"""
186
+ params = {k: v for k, v in {
187
+ "fromDate": from_date,
188
+ "untilDate": until_date,
189
+ "fullSegmentsOnly": str(full_segments_only).lower() if isinstance(full_segments_only, bool) else full_segments_only,
190
+ }.items() if v is not None}
191
+ if not agreement_id and not self.acerta._agreement_ids:
192
+ self.get()
193
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
194
+ frames = []
195
+ headers = {"Accept-Language": accept_language} if accept_language else None
196
+ for agr_id in ids:
197
+ request_headers = self.acerta.session.headers.copy()
198
+ if headers:
199
+ request_headers.update(headers)
200
+ response = self.acerta.session.get(
201
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/basic-information",
202
+ params=params,
203
+ headers=request_headers,
204
+ timeout=self.acerta.TIMEOUT,
205
+ )
206
+ response.raise_for_status()
207
+ raw = response.json()
208
+ df = pd.json_normalize(
209
+ raw,
210
+ record_path=["basicInformationSegments"],
211
+ meta=["agreementId", ["agreementType", "code"], ["agreementType", "description"]],
212
+ errors="ignore",
213
+ sep=".",
214
+ )
215
+ frames.append(df)
216
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
217
+ return Functions.validate_data(combined, AgreementBasicInformationGet, debug=self.acerta.debug)
218
+
219
+ def get_employments(
220
+ self,
221
+ agreement_id: Optional[str] = None,
222
+ from_date: Optional[str] = None,
223
+ until_date: Optional[str] = None,
224
+ accept_language: str = "en",
225
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
226
+ """GET /v3/agreements/{agreementId}/employments"""
227
+ params = {k: v for k, v in {
228
+ "fromDate": from_date,
229
+ "untilDate": until_date,
230
+ }.items() if v is not None}
231
+ if not agreement_id and not self.acerta._agreement_ids:
232
+ self.get()
233
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
234
+ frames = []
235
+ headers = {"Accept-Language": accept_language} if accept_language else None
236
+ for agr_id in ids:
237
+ request_headers = self.acerta.session.headers.copy()
238
+ if headers:
239
+ request_headers.update(headers)
240
+ response = self.acerta.session.get(
241
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/employments",
242
+ params=params,
243
+ headers=request_headers,
244
+ timeout=self.acerta.TIMEOUT,
245
+ )
246
+ response.raise_for_status()
247
+ raw = response.json()
248
+ df = pd.json_normalize(
249
+ raw,
250
+ record_path=["employments"],
251
+ meta=["agreementId"],
252
+ errors="ignore",
253
+ sep=".",
254
+ )
255
+ frames.append(df)
256
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
257
+ return Functions.validate_data(combined, AgreementEmploymentsGet, debug=self.acerta.debug)
258
+
259
+ def get_working_time(
260
+ self,
261
+ agreement_id: Optional[str] = None,
262
+ from_date: Optional[str] = None,
263
+ until_date: Optional[str] = None,
264
+ full_segments_only: Optional[bool] = None,
265
+ accept_language: str = "en",
266
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
267
+ """GET /v3/agreements/{agreementId}/working-time"""
268
+ params = {k: v for k, v in {
269
+ "fromDate": from_date,
270
+ "untilDate": until_date,
271
+ "fullSegmentsOnly": str(full_segments_only).lower() if isinstance(full_segments_only, bool) else full_segments_only,
272
+ }.items() if v is not None}
273
+ if not agreement_id and not self.acerta._agreement_ids:
274
+ self.get()
275
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
276
+ frames = []
277
+ headers = {"Accept-Language": accept_language} if accept_language else None
278
+ for agr_id in ids:
279
+ request_headers = self.acerta.session.headers.copy()
280
+ if headers:
281
+ request_headers.update(headers)
282
+ response = self.acerta.session.get(
283
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/working-time",
284
+ params=params,
285
+ headers=request_headers,
286
+ timeout=self.acerta.TIMEOUT,
287
+ )
288
+ response.raise_for_status()
289
+ raw = response.json()
290
+ df = pd.json_normalize(
291
+ raw,
292
+ record_path=["workingTimeSegments"],
293
+ meta=["agreementId"],
294
+ errors="ignore",
295
+ sep=".",
296
+ )
297
+ frames.append(df)
298
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
299
+ return Functions.validate_data(combined, AgreementWorkingTimeGet, debug=self.acerta.debug)
300
+
301
+
302
+ def get_commuting(
303
+ self,
304
+ agreement_id: Optional[str] = None,
305
+ from_date: Optional[str] = None,
306
+ until_date: Optional[str] = None,
307
+ full_segments_only: Optional[bool] = None,
308
+ accept_language: str = "en",
309
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
310
+ """GET /v3/agreements/{agreementId}/commuting"""
311
+ params = {k: v for k, v in {
312
+ "fromDate": from_date,
313
+ "untilDate": until_date,
314
+ "fullSegmentsOnly": str(full_segments_only).lower() if isinstance(full_segments_only, bool) else full_segments_only,
315
+ }.items() if v is not None}
316
+ if not agreement_id and not self.acerta._agreement_ids:
317
+ self.get()
318
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
319
+ frames = []
320
+ headers = {"Accept-Language": accept_language} if accept_language else None
321
+ for agr_id in ids:
322
+ request_headers = self.acerta.session.headers.copy()
323
+ if headers:
324
+ request_headers.update(headers)
325
+ response = self.acerta.session.get(
326
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/commuting",
327
+ params=params,
328
+ headers=request_headers,
329
+ timeout=self.acerta.TIMEOUT,
330
+ )
331
+ response.raise_for_status()
332
+ raw = response.json()
333
+ df = pd.json_normalize(
334
+ raw,
335
+ record_path=["commutingSegments"],
336
+ meta=["agreementId"],
337
+ errors="ignore",
338
+ sep=".",
339
+ )
340
+ frames.append(df)
341
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
342
+ return Functions.validate_data(combined, AgreementCommutingGet, debug=self.acerta.debug)
343
+
344
+ def get_custom_fields(
345
+ self,
346
+ agreement_id: Optional[str] = None,
347
+ from_date: Optional[str] = None,
348
+ until_date: Optional[str] = None,
349
+ custom_field_names: Optional[list] = None,
350
+ accept_language: str = "en",
351
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
352
+ """GET /v3/agreements/{agreementId}/custom-fields"""
353
+ params_dict: Dict[str, Any] = {
354
+ "fromDate": from_date,
355
+ "untilDate": until_date,
356
+ }
357
+ if custom_field_names:
358
+ params_dict["customFieldNames"] = ",".join(str(x) for x in custom_field_names)
359
+ params = {k: v for k, v in params_dict.items() if v is not None}
360
+ if not agreement_id and not self.acerta._agreement_ids:
361
+ self.get()
362
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
363
+ frames = []
364
+ headers = {"Accept-Language": accept_language} if accept_language else None
365
+ for agr_id in ids:
366
+ request_headers = self.acerta.session.headers.copy()
367
+ if headers:
368
+ request_headers.update(headers)
369
+ response = self.acerta.session.get(
370
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/custom-fields",
371
+ params=params,
372
+ headers=request_headers,
373
+ timeout=self.acerta.TIMEOUT,
374
+ )
375
+ response.raise_for_status()
376
+ raw = response.json()
377
+ df = pd.json_normalize(
378
+ raw,
379
+ record_path=["customFieldsSegments"],
380
+ meta=["agreementId"],
381
+ errors="ignore",
382
+ sep=".",
383
+ )
384
+ frames.append(df)
385
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
386
+ return Functions.validate_data(combined, AgreementCustomFieldsGet, debug=self.acerta.debug)
387
+
388
+ def get_cost_center_allocation(
389
+ self,
390
+ agreement_id: Optional[str] = None,
391
+ from_date: Optional[str] = None,
392
+ until_date: Optional[str] = None,
393
+ accept_language: str = "en",
394
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
395
+ """GET /v3/agreements/{agreementId}/cost-center-allocation"""
396
+ params = {k: v for k, v in {
397
+ "fromDate": from_date,
398
+ "untilDate": until_date,
399
+ }.items() if v is not None}
400
+ if not agreement_id and not self.acerta._agreement_ids:
401
+ self.get()
402
+ ids = [agreement_id] if agreement_id else self.acerta._agreement_ids
403
+ frames = []
404
+ headers = {"Accept-Language": accept_language} if accept_language else None
405
+ for agr_id in ids:
406
+ request_headers = self.acerta.session.headers.copy()
407
+ if headers:
408
+ request_headers.update(headers)
409
+ response = self.acerta.session.get(
410
+ url=f"{self.acerta.base_url}/{self.base_uri}/{agr_id}/cost-center-allocation",
411
+ params=params,
412
+ headers=request_headers,
413
+ timeout=self.acerta.TIMEOUT,
414
+ )
415
+ response.raise_for_status()
416
+ raw = response.json()
417
+ df = pd.json_normalize(
418
+ raw,
419
+ record_path=["costCenterAllocation"],
420
+ meta=["agreementId"],
421
+ errors="ignore",
422
+ sep=".",
423
+ )
424
+ frames.append(df)
425
+ combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
426
+ return Functions.validate_data(combined, AgreementCostCenterAllocationGet, debug=self.acerta.debug)