pyxecm 1.3.0__py3-none-any.whl → 1.5__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.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

@@ -0,0 +1,1056 @@
1
+ """
2
+ SuccessFactors Module to interact with the SuccessFactors API
3
+
4
+ See:
5
+ https://community.sap.com/t5/enterprise-resource-planning-blogs-by-members/how-to-initiate-an-oauth-connection-to-successfactors-employee-central/ba-p/13332388
6
+ https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/78b1d8aac783455684a7de7a8a5b0c04.html
7
+
8
+ Class: SuccessFactors
9
+ Methods:
10
+
11
+ __init__ : class initializer
12
+ config : Returns config data set
13
+ credentials: Returns the token data
14
+ idp_data: Return the IDP data used to request the SAML assertion
15
+ request_header: Returns the request header for SuccessFactors API calls
16
+ parse_request_response: Parse the REST API responses and convert
17
+ them to Python dict in a safe way
18
+ exist_result_item: Check if an dict item is in the response
19
+ of the SuccessFactors API call
20
+ get_result_value: Check if a defined value (based on a key) is in the SuccessFactors API response
21
+
22
+ get_saml_assertion: Get SAML Assertion for SuccessFactors authentication
23
+ authenticate : Authenticates at SuccessFactors API
24
+
25
+ get_country: Get information for a Country / Countries
26
+ get_user: Get a SuccessFactors user based on its ID.
27
+ get_user_account: Get information for a SuccessFactors User Account
28
+ update_user: Update user data. E.g. update the user password or email.
29
+ get_employee: Get a list of employee(s) matching given criterias.
30
+ get_entities_metadata: Get the schema (metadata) for a list of entities
31
+ (list can be empty to get it for all)
32
+ get_entity_metadata: Get the schema (metadata) for an entity
33
+ """
34
+
35
+ __author__ = "Dr. Marc Diefenbruch"
36
+ __copyright__ = "Copyright 2024, OpenText"
37
+ __credits__ = ["Kai-Philip Gatzweiler"]
38
+ __maintainer__ = "Dr. Marc Diefenbruch"
39
+ __email__ = "mdiefenb@opentext.com"
40
+
41
+ import json
42
+ import logging
43
+ import time
44
+ import urllib.parse
45
+ import requests
46
+
47
+ import xmltodict
48
+
49
+ logger = logging.getLogger("pyxecm.customizer.sucessfactors")
50
+
51
+ request_login_headers = {
52
+ "Content-Type": "application/x-www-form-urlencoded", # "application/json",
53
+ "Accept": "application/json",
54
+ }
55
+
56
+ REQUEST_TIMEOUT = 60
57
+ REQUEST_MAX_RETRIES = 5
58
+ REQUEST_RETRY_DELAY = 60
59
+
60
+ class SuccessFactors(object):
61
+ """Used to retrieve and automate stettings in SuccessFactors."""
62
+
63
+ _config: dict
64
+ _access_token = None
65
+ _assertion = None
66
+
67
+ def __init__(
68
+ self,
69
+ base_url: str,
70
+ as_url: str,
71
+ client_id: str,
72
+ client_secret: str,
73
+ username: str = "",
74
+ password: str = "",
75
+ company_id: str = "",
76
+ authorization_url: str = "",
77
+ ):
78
+ """Initialize the SuccessFactors object
79
+
80
+ Args:
81
+ base_url (str): base URL of the SuccessFactors tenant
82
+ authorization_url (str): authorization URL of the SuccessFactors tenant, typically ending with "/services/oauth2/token"
83
+ client_id (str): SuccessFactors Client ID
84
+ client_secret (str): SuccessFactors Client Secret
85
+ username (str): user name in SuccessFactors
86
+ password (str): password of the user
87
+ authorization_url (str, optional): URL for SuccessFactors login. If not given it will be constructed with default values
88
+ using base_url
89
+ """
90
+
91
+ successfactors_config = {}
92
+
93
+ # this class assumes that the base URL is provided without
94
+ # a trailing "/". Otherwise the trailing slash is removed.
95
+ if base_url.endswith("/"):
96
+ base_url = base_url[:-1]
97
+
98
+ # Set the authentication endpoints and credentials
99
+ successfactors_config["baseUrl"] = base_url
100
+ successfactors_config["asUrl"] = as_url
101
+ successfactors_config["clientId"] = client_id
102
+ successfactors_config["clientSecret"] = client_secret
103
+ successfactors_config["username"] = username.split("@")[
104
+ 0
105
+ ] # we don't want the company ID in the user name
106
+ successfactors_config["password"] = password
107
+ if company_id:
108
+ successfactors_config["companyId"] = company_id
109
+ elif "@" in username:
110
+ # if the company ID is not provided as a parameter
111
+ # we check if it is included in the username:
112
+ company_id = username.split("@")[1]
113
+ successfactors_config["companyId"] = company_id
114
+ if authorization_url:
115
+ successfactors_config["authenticationUrl"] = authorization_url
116
+ else:
117
+ successfactors_config["authenticationUrl"] = (
118
+ successfactors_config["baseUrl"] + "/oauth/token"
119
+ )
120
+
121
+ successfactors_config["idpUrl"] = (
122
+ successfactors_config["baseUrl"] + "/oauth/idp"
123
+ )
124
+
125
+ if not username:
126
+ # Set the data for the token request
127
+ successfactors_config["authenticationData"] = {
128
+ "grant_type": "client_credentials",
129
+ "client_id": client_id,
130
+ "client_secret": client_secret,
131
+ # "username": successfactors_config["username"],
132
+ # "password": password,
133
+ }
134
+ else:
135
+ # Set the data for the token request
136
+ successfactors_config["authenticationData"] = {
137
+ # "grant_type": "password",
138
+ "grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer",
139
+ "company_id": successfactors_config["companyId"],
140
+ "username": successfactors_config["username"],
141
+ "password": password,
142
+ "client_id": client_id,
143
+ "client_secret": client_secret,
144
+ }
145
+
146
+ successfactors_config["idpData"] = {
147
+ "client_id": client_id,
148
+ "user_id": successfactors_config["username"],
149
+ # "use_email": True,
150
+ "token_url": successfactors_config["authenticationUrl"],
151
+ "private_key": client_secret,
152
+ }
153
+
154
+ self._config = successfactors_config
155
+
156
+ # end method definition
157
+
158
+ def config(self) -> dict:
159
+ """Returns the configuration dictionary
160
+
161
+ Returns:
162
+ dict: Configuration dictionary
163
+ """
164
+ return self._config
165
+
166
+ # end method definition
167
+
168
+ def credentials(self) -> dict:
169
+ """Return the login credentials
170
+
171
+ Returns:
172
+ dict: dictionary with login credentials for SuccessFactors
173
+ """
174
+ return self.config()["authenticationData"]
175
+
176
+ # end method definition
177
+
178
+ def idp_data(self) -> dict:
179
+ """Return the IDP data used to request the SAML assertion
180
+
181
+ Returns:
182
+ dict: dictionary with IDP data for SuccessFactors
183
+ """
184
+ return self.config()["idpData"]
185
+
186
+ # end method definition
187
+
188
+ def request_header(self, content_type: str = "application/json") -> dict:
189
+ """Returns the request header used for Application calls.
190
+ Consists of Bearer access token and Content Type
191
+
192
+ Args:
193
+ content_type (str, optional): content type for the request
194
+ Return:
195
+ dict: request header values
196
+ """
197
+
198
+ request_header = {
199
+ "Authorization": "Bearer {}".format(self._access_token),
200
+ "Content-Type": content_type,
201
+ "Accept": content_type,
202
+ }
203
+ return request_header
204
+
205
+ # end method definition
206
+
207
+ def parse_request_response(
208
+ self,
209
+ response_object: requests.Response,
210
+ additional_error_message: str = "",
211
+ show_error: bool = True,
212
+ ) -> dict | None:
213
+ """Converts the request response (JSon) to a Python dict in a safe way
214
+ that also handles exceptions. It first tries to load the response.text
215
+ via json.loads() that produces a dict output. Only if response.text is
216
+ not set or is empty it just converts the response_object to a dict using
217
+ the vars() built-in method.
218
+
219
+ Args:
220
+ response_object (object): this is reponse object delivered by the request call
221
+ additional_error_message (str, optional): use a more specific error message
222
+ in case of an error
223
+ show_error (bool): True: write an error to the log file
224
+ False: write a warning to the log file
225
+ Returns:
226
+ dict: response information or None in case of an error
227
+ """
228
+
229
+ if not response_object:
230
+ return None
231
+
232
+ try:
233
+ if response_object.text:
234
+ dict_object = json.loads(response_object.text)
235
+ else:
236
+ dict_object = vars(response_object)
237
+ except json.JSONDecodeError as exception:
238
+ if additional_error_message:
239
+ message = "Cannot decode response as JSon. {}; error -> {}".format(
240
+ additional_error_message, exception
241
+ )
242
+ else:
243
+ message = "Cannot decode response as JSon; error -> {}".format(
244
+ exception
245
+ )
246
+ if show_error:
247
+ logger.error(message)
248
+ else:
249
+ logger.warning(message)
250
+ return None
251
+ else:
252
+ return dict_object
253
+
254
+ # end method definition
255
+
256
+ def exist_result_item(self, response: dict, key: str, value: str) -> bool:
257
+ """Check existence of key / value pair in the response properties of an SuccessFactors API call.
258
+
259
+ Args:
260
+ response (dict): REST response from an SuccessFactors API call
261
+ key (str): property name (key)
262
+ value (str): value to find in the item with the matching key
263
+ Returns:
264
+ bool: True if the value was found, False otherwise
265
+ """
266
+
267
+ if not response:
268
+ return False
269
+
270
+ if "d" in response:
271
+ data = response["d"]
272
+ if not key in data:
273
+ return False
274
+ if value == data[key]:
275
+ return True
276
+ else:
277
+ if not key in response:
278
+ return False
279
+ if value == response[key]:
280
+ return True
281
+
282
+ return False
283
+
284
+ # end method definition
285
+
286
+ def get_result_value(
287
+ self,
288
+ response: dict,
289
+ key: str,
290
+ index: int = 0,
291
+ ) -> str | None:
292
+ """Get value of a result property with a given key of an SuccessFactors API call.
293
+
294
+ Args:
295
+ response (dict): REST response from an SuccessFactors REST Call
296
+ key (str): property name (key)
297
+ index (int, optional): Index to use (1st element has index 0).
298
+ Defaults to 0.
299
+ Returns:
300
+ str: value for the key, None otherwise
301
+ """
302
+
303
+ if not response or not "d" in response:
304
+ return None
305
+
306
+ data = response["d"]
307
+
308
+ # list response types are wrapped in a "results" element
309
+ # which is of type list
310
+ if "results" in data:
311
+ results = data["results"]
312
+ if not results or not isinstance(results, list):
313
+ return None
314
+ try:
315
+ value = results[index][key]
316
+ except IndexError as e:
317
+ logger.error(
318
+ "Index error with index -> %s and key -> %s: %s",
319
+ str(index),
320
+ key,
321
+ str(e),
322
+ )
323
+ return None
324
+ except KeyError as e:
325
+ logger.error(
326
+ "Key error with index -> %s and key -> %s: %s",
327
+ str(index),
328
+ key,
329
+ str(e),
330
+ )
331
+ return None
332
+ else: # simple response - try to find key in response directly:
333
+ if not key in data:
334
+ return None
335
+ value = data[key]
336
+
337
+ return value
338
+
339
+ # end method definition
340
+
341
+ def get_saml_assertion(self) -> str | None:
342
+ """Get SAML Assertion for SuccessFactors authentication.
343
+
344
+ Args:
345
+ None
346
+ Returns:
347
+ str: Assertion. Also stores access token in self._assertion. None in case of error
348
+ """
349
+
350
+ request_url = self.config()["idpUrl"]
351
+
352
+ # request_header = request_login_headers
353
+
354
+ logger.debug("Requesting SuccessFactors SAML Assertion from -> %s", request_url)
355
+
356
+ idp_post_body = self.config()["idpData"]
357
+
358
+ response = None
359
+ self._assertion = None
360
+
361
+ try:
362
+ response = requests.post(
363
+ request_url,
364
+ data=idp_post_body,
365
+ # headers=request_header,
366
+ timeout=REQUEST_TIMEOUT,
367
+ )
368
+ except requests.exceptions.ConnectionError as exception:
369
+ logger.error(
370
+ "Unable to get SAML assertion from -> %s : %s",
371
+ self.config()["idpUrl"],
372
+ exception,
373
+ )
374
+ return None
375
+
376
+ if response.ok:
377
+ assertion = response.text
378
+ self._assertion = assertion
379
+ logger.debug("Assertion -> %s", self._assertion)
380
+ return assertion
381
+
382
+ logger.error(
383
+ "Failed to request an SuccessFactors SAML Assertion; error -> %s",
384
+ response.text,
385
+ )
386
+ return None
387
+
388
+ # end method definition
389
+
390
+ def authenticate(self, revalidate: bool = False) -> str | None:
391
+ """Authenticate at SuccessFactors with client ID and client secret.
392
+
393
+ Args:
394
+ revalidate (bool, optional): determinse if a re-athentication is enforced
395
+ (e.g. if session has timed out with 401 error)
396
+ Returns:
397
+ str: Access token. Also stores access token in self._access_token. None in case of error
398
+ """
399
+
400
+ if not self._assertion:
401
+ self._assertion = self.get_saml_assertion()
402
+
403
+ # Already authenticated and session still valid?
404
+ if self._access_token and not revalidate:
405
+ logger.debug(
406
+ "Session still valid - return existing access token -> %s",
407
+ str(self._access_token),
408
+ )
409
+ return self._access_token
410
+
411
+ request_url = self.config()["authenticationUrl"]
412
+
413
+ # request_header = request_login_headers
414
+
415
+ logger.debug("Requesting SuccessFactors Access Token from -> %s", request_url)
416
+
417
+ authenticate_post_body = self.credentials()
418
+ authenticate_post_body["assertion"] = self._assertion
419
+
420
+ response = None
421
+ self._access_token = None
422
+
423
+ try:
424
+ response = requests.post(
425
+ request_url,
426
+ data=authenticate_post_body,
427
+ # headers=request_header,
428
+ timeout=REQUEST_TIMEOUT,
429
+ )
430
+ except requests.exceptions.ConnectionError as exception:
431
+ logger.warning(
432
+ "Unable to connect to -> %s : %s",
433
+ self.config()["authenticationUrl"],
434
+ exception,
435
+ )
436
+ return None
437
+
438
+ if response.ok:
439
+ authenticate_dict = self.parse_request_response(response)
440
+ if not authenticate_dict or not "access_token" in authenticate_dict:
441
+ return None
442
+ # Store authentication access_token:
443
+ self._access_token = authenticate_dict["access_token"]
444
+ logger.debug("Access Token -> %s", self._access_token)
445
+ else:
446
+ logger.error(
447
+ "Failed to request an SuccessFactors Access Token; error -> %s",
448
+ response.text,
449
+ )
450
+ return None
451
+
452
+ return self._access_token
453
+
454
+ # end method definition
455
+
456
+ def get_country(self, code: str = "") -> dict | None:
457
+ """Get information for a Country / Countries
458
+
459
+ Args:
460
+ code (str): 3 character code for contry selection, like "USA"
461
+
462
+ Returns:
463
+ dict | None: Country details
464
+
465
+ Example return data in "d" dictionary:
466
+
467
+ {
468
+ '__metadata': {
469
+ 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/UserAccount('twalker')",
470
+ 'type': 'SFOData.UserAccount'
471
+ },
472
+ 'username': 'twalker',
473
+ 'lastModifiedDateTime': '/Date(1692701804000+0000)/',
474
+ 'accountUuid': '5c7390e0-d9d2-e348-1700-2b02b3a61aa5',
475
+ 'createdDateTime': '/Date(1420745485000+0000)/',
476
+ 'timeZone': 'US/Eastern',
477
+ 'lastInactivationDateTime': None,
478
+ 'accountIsLocked': 'FALSE',
479
+ 'accountStatus': 'ACTIVE',
480
+ 'defaultLocale': 'en_US',
481
+ 'lastLoginFailedDateTime': None,
482
+ 'accountId': '90',
483
+ 'sapGlobalUserId': None,
484
+ 'personIdExternal': '82094',
485
+ 'userType': 'employee',
486
+ 'email': 'twalker@m365x41497014.onmicrosoft.com',
487
+ 'user': {'__deferred': {...}}
488
+ }
489
+ """
490
+
491
+ if not self._access_token:
492
+ self.authenticate()
493
+
494
+ if code:
495
+ request_url = self.config()["asUrl"] + "Country(code='{}')".format(
496
+ code
497
+ ) # ,effectiveStartDate=datetime'1900-01-01T00:00:00'
498
+ else:
499
+ request_url = self.config()["asUrl"] + "Country"
500
+
501
+ request_header = self.request_header()
502
+
503
+ response = requests.get(
504
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
505
+ )
506
+ if response.status_code == 200:
507
+ return self.parse_request_response(response)
508
+ else:
509
+ logger.error(
510
+ "Failed to retrieve country data; status -> %s; error -> %s",
511
+ response.status_code,
512
+ response.text,
513
+ )
514
+ return None
515
+
516
+ # end method definition
517
+
518
+ def get_user(
519
+ self,
520
+ user_id: str = "", # this is NOT the username but really an ID like 106020
521
+ field_name: str = "",
522
+ field_value: str = "",
523
+ field_operation: str = "eq",
524
+ max_results: int = 1,
525
+ ) -> dict | None:
526
+ """Get information for a User Account
527
+ Inactive users are not returned by default. To query inactive users,
528
+ you can explicitly include the status in a $filter or use a key predicate.
529
+ If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.
530
+
531
+ Args:
532
+ user_id (str): login name of the user (e.g. "twalker")
533
+
534
+ Returns:
535
+ dict | None: User Account details
536
+
537
+ Example return data in "d" dictionary:
538
+
539
+ {
540
+ '__metadata': {
541
+ 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/User('106020')",
542
+ 'type': 'SFOData.User'
543
+ },
544
+ 'userId': '106020',
545
+ 'salaryBudgetFinalSalaryPercentage': None,
546
+ 'dateOfCurrentPosition': '/Date(1388534400000)/',
547
+ 'matrix1Label': None,
548
+ 'salary': '79860.0',
549
+ 'objective': '0.0',
550
+ 'ssn': None,
551
+ 'state': 'New South Wales',
552
+ 'issueComments': None,
553
+ 'timeZone': 'Australia/Sydney',
554
+ 'defaultLocale': 'en_US',
555
+ 'nationality': None,
556
+ 'salaryBudgetLumpsumPercentage': None,
557
+ 'sysCostOfSource': None,
558
+ 'ethnicity': None,
559
+ 'displayName': 'Mark Burke',
560
+ 'payGrade': 'GR-06',
561
+ 'nickname': None,
562
+ 'email': 'Mark.Burke@bestrunsap.com',
563
+ 'salaryBudgetExtra2Percentage': None,
564
+ 'stockBudgetOther1Amount': None,
565
+ 'raiseProrating': None,
566
+ 'sysStartingSalary': None,
567
+ 'finalJobCode': None,
568
+ 'lumpsum2Target': None,
569
+ 'stockBudgetOptionAmount': None,
570
+ 'country': 'Australia',
571
+ 'lastModifiedDateTime': '/Date(1689005658000+0000)/',
572
+ 'stockBudgetStockAmount': None,
573
+ 'sciLastModified': None,
574
+ 'criticalTalentComments': None,
575
+ 'homePhone': None,
576
+ 'veteranSeparated': False,
577
+ 'stockBudgetOther2Amount': None,
578
+ 'firstName': 'Mark',
579
+ 'stockBudgetUnitAmount': None,
580
+ 'salutation': '10808',
581
+ 'impactOfLoss': None,
582
+ 'benchStrength': None,
583
+ 'sysSource': None,
584
+ 'futureLeader': None,
585
+ 'title': 'HR Business Partner',
586
+ 'meritEffectiveDate': None,
587
+ 'veteranProtected': False,
588
+ 'lumpsumTarget': None,
589
+ 'employeeClass': 'Active',
590
+ 'hireDate': '/Date(1388534400000)/',
591
+ 'matrix2Label': None, 'salaryLocal': None,
592
+ 'citizenship': None,
593
+ 'reasonForLeaving': None,
594
+ 'riskOfLoss': None,
595
+ 'location': 'Sydney (8510-0001)',
596
+ 'reloComments': None,
597
+ 'username': 'mburke',
598
+ 'serviceDate': None,
599
+ 'reviewFreq': None,
600
+ 'salaryBudgetTotalRaisePercentage': None,
601
+ ...
602
+ }
603
+ """
604
+
605
+ if not self._access_token:
606
+ self.authenticate()
607
+
608
+ request_url = self.config()["asUrl"] + "User"
609
+ if user_id:
610
+ # querying a user by key predicate:
611
+ request_url += "('{}')".format(user_id)
612
+
613
+ # Add query parameters (these are NOT passed via JSon body!)
614
+ query = {}
615
+ if field_name and field_value:
616
+ query["$filter"] = "{} {} {}".format(
617
+ field_name, field_operation, field_value
618
+ )
619
+ if max_results > 0:
620
+ query["$top"] = max_results
621
+ encoded_query = urllib.parse.urlencode(query, doseq=True)
622
+ if query:
623
+ request_url += "?" + encoded_query
624
+
625
+ request_header = self.request_header()
626
+
627
+ response = requests.get(
628
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
629
+ )
630
+ if response.status_code == 200:
631
+ return self.parse_request_response(response)
632
+ else:
633
+ logger.error(
634
+ "Failed to retrieve user data; status -> %s; error -> %s",
635
+ response.status_code,
636
+ response.text,
637
+ )
638
+ return None
639
+
640
+ # end method definition
641
+
642
+ def get_user_account(self, username: str) -> dict | None:
643
+ """Get information for a SuccessFactors User Account
644
+ Inactive users are not returned by default. To query inactive users,
645
+ you can explicitly include the status in a $filter or use a key predicate.
646
+ If you want to query all users, use query option $filter=status in 't','f','T','F','e','d'.
647
+
648
+ Args:
649
+ username (str): login name of the user (e.g. "twalker")
650
+
651
+ Returns:
652
+ dict | None: User Account details
653
+
654
+ Example return data in "d" dictionary:
655
+
656
+ {
657
+ '__metadata': {
658
+ 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/UserAccount('twalker')",
659
+ 'type': 'SFOData.UserAccount'
660
+ },
661
+ 'username': 'twalker',
662
+ 'lastModifiedDateTime': '/Date(1692701804000+0000)/',
663
+ 'accountUuid': '5c7390e0-d9d2-e348-1700-2b02b3a61aa5',
664
+ 'createdDateTime': '/Date(1420745485000+0000)/',
665
+ 'timeZone': 'US/Eastern',
666
+ 'lastInactivationDateTime': None,
667
+ 'accountIsLocked': 'FALSE',
668
+ 'accountStatus': 'ACTIVE',
669
+ 'defaultLocale': 'en_US',
670
+ 'lastLoginFailedDateTime': None,
671
+ 'accountId': '90',
672
+ 'sapGlobalUserId': None,
673
+ 'personIdExternal': '82094',
674
+ 'userType': 'employee',
675
+ 'email': 'twalker@m365x41497014.onmicrosoft.com',
676
+ 'user': {'__deferred': {...}}
677
+ }
678
+ """
679
+
680
+ if not self._access_token:
681
+ self.authenticate()
682
+
683
+ request_url = self.config()["asUrl"] + "UserAccount('{}')".format(username)
684
+
685
+ request_header = self.request_header()
686
+
687
+ retries = 0
688
+
689
+ while True:
690
+ try:
691
+ response = requests.get(
692
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
693
+ )
694
+ response.raise_for_status() # This will raise an HTTPError for bad responses
695
+ return self.parse_request_response(response)
696
+ except requests.exceptions.HTTPError as http_err:
697
+ logger.error(
698
+ "Failed to retrieve user data from SuccessFactors; status -> %s; error -> %s",
699
+ response.status_code,
700
+ str(http_err),
701
+ )
702
+ except requests.exceptions.Timeout:
703
+ logger.warning(
704
+ "Failed to retrieve user data from SuccessFactors. The request timed out.",
705
+ )
706
+ except requests.exceptions.ConnectionError as conn_err:
707
+ logger.error(
708
+ "Cannot connect to SuccessFactors to retrieve user data; status -> %s; error -> %s",
709
+ response.status_code,
710
+ str(conn_err),
711
+ )
712
+ except requests.exceptions.RequestException as req_err:
713
+ logger.error(
714
+ "Failed to retrieve user data from SuccessFactors; status -> %s; error -> %s",
715
+ response.status_code,
716
+ str(req_err),
717
+ )
718
+ retries += 1
719
+ if retries <= REQUEST_MAX_RETRIES:
720
+ logger.info("Retrying in %s seconds...", str(REQUEST_RETRY_DELAY))
721
+ time.sleep(retries * REQUEST_RETRY_DELAY)
722
+ else:
723
+ break
724
+
725
+ return None
726
+
727
+ # end method definition
728
+
729
+ def update_user(
730
+ self,
731
+ user_id: str, # this is NOT the username but really an ID like 106020
732
+ update_data: dict,
733
+ ) -> dict:
734
+ """Update user data. E.g. update the user password or email.
735
+ See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/47c39724e7654b99a6be2f71fce1c50b.html?locale=en-US
736
+
737
+ Args:
738
+ user_id (str): ID of the user (e.g. 106020)
739
+ update_data (dict): Update data
740
+ Returns:
741
+ dict: Request response or None if an error occured.
742
+ """
743
+
744
+ if not self._access_token:
745
+ self.authenticate()
746
+
747
+ request_url = self.config()["asUrl"] + "User('{}')".format(user_id)
748
+
749
+ request_header = self.request_header()
750
+ # We need to use a special MERGE header to tell
751
+ # SuccessFactors to only change the new / provided fields:
752
+ request_header["X-HTTP-METHOD"] = "MERGE"
753
+
754
+ response = requests.post(
755
+ request_url,
756
+ headers=request_header,
757
+ json=update_data,
758
+ timeout=REQUEST_TIMEOUT,
759
+ )
760
+ if response.ok:
761
+ logger.debug("User with ID -> %s updated successfully.", user_id)
762
+ return self.parse_request_response(response)
763
+ else:
764
+ logger.error(
765
+ "Failed to update user with ID -> %s; status -> %s; error -> %s",
766
+ user_id,
767
+ response.status_code,
768
+ response.text,
769
+ )
770
+ return None
771
+
772
+ # end method definition
773
+
774
+ def get_employee(
775
+ self,
776
+ entity: str = "PerPerson",
777
+ field_name: str = "",
778
+ field_value: str = "",
779
+ field_operation: str = "eq",
780
+ max_results: int = 1,
781
+ ) -> dict | None:
782
+ """Get a list of employee(s) matching given criterias.
783
+
784
+ Args:
785
+ entity (str, optional): Entity type to query. Examples are "PerPerson" (default),
786
+ "PerPersonal", "PerEmail", "PersonKey", ...
787
+ field_name (str): Field to search in. E.g. personIdExternal, firstName, lastName,
788
+ fullName, email, dateOfBirth, gender, nationality, maritalStatus,
789
+ employeeId
790
+ field_value (str): Value to match in the Field
791
+
792
+ Returns:
793
+ dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.
794
+
795
+ Example result values for "PerPerson" inside the "d" structure:
796
+
797
+ "results": [
798
+ {
799
+ '__metadata': {...},
800
+ 'personIdExternal': '109031',
801
+ 'lastModifiedDateTime': '/Date(1442346839000+0000)/',
802
+ 'lastModifiedBy': 'admindlr',
803
+ 'createdDateTime': '/Date(1442346265000+0000)/',
804
+ 'dateOfBirth': '/Date(-501206400000)/',
805
+ 'perPersonUuid': '0378B0E6F41444EBB90345B56D537D3D',
806
+ 'createdOn': '/Date(1442353465000)/',
807
+ 'lastModifiedOn': '/Date(1442354039000)/',
808
+ 'countryOfBirth': 'RUS',
809
+ 'createdBy': 'admindlr',
810
+ 'regionOfBirth': None,
811
+ 'personId': '771',
812
+ 'personalInfoNav': {...},
813
+ 'emergencyContactNav': {...},
814
+ 'secondaryAssignmentsNav': {...},
815
+ 'personEmpTerminationInfoNav': {...},
816
+ 'phoneNav': {...},
817
+ 'employmentNav': {...},
818
+ ...
819
+ }
820
+ ]
821
+
822
+ Example result values for "PerPersonal" inside the "d" structure:
823
+
824
+ "results": [
825
+ {
826
+ '__metadata': {
827
+ 'uri': "https://apisalesdemo2.successfactors.eu/odata/v2/PerPersonal(personIdExternal='108729',startDate=datetime'2017-03-13T00:00:00')",
828
+ 'type': 'SFOData.PerPersonal'
829
+ },
830
+ 'personIdExternal': '108729',
831
+ 'startDate': '/Date(1489363200000)/',
832
+ 'lastModifiedDateTime': '/Date(1489442337000+0000)/',
833
+ 'endDate': '/Date(253402214400000)/',
834
+ 'createdDateTime': '/Date(1489442337000+0000)/',
835
+ 'suffix': None,
836
+ 'attachmentId': None,
837
+ 'preferredName': 'Hillary',
838
+ 'lastNameAlt1': None,
839
+ 'firstName': 'Hillary',
840
+ 'nationality': 'USA',
841
+ 'salutation': '30085',
842
+ 'maritalStatus': '10825',
843
+ 'lastName': 'Lawson',
844
+ 'gender': 'F',
845
+ 'firstNameAlt1': None,
846
+ 'createdOn': '/Date(1489445937000)/',
847
+ 'middleNameAlt1': None,
848
+ 'lastModifiedBy': '82094',
849
+ 'lastModifiedOn': '/Date(1489445937000)/',
850
+ 'createdBy': '82094',
851
+ 'middleName': None,
852
+ 'nativePreferredLang': '10249',
853
+ 'localNavAUS': {'__deferred': {...}},
854
+ 'localNavBGD': {'__deferred': {...}},
855
+ 'localNavHKG': {'__deferred': {...}},
856
+ 'localNavMYS': {'__deferred': {...}},
857
+ 'localNavAUT': {'__deferred': {...}},
858
+ 'localNavLKA': {'__deferred': {...}},
859
+ 'localNavPOL': {'__deferred': {...}},
860
+ 'localNavCZE': {'__deferred': {...}},
861
+ 'localNavTWN': {'__deferred': {...}},
862
+ 'localNavARE': {'__deferred': {...}},
863
+ 'localNavARG': {'__deferred': {...}},
864
+ 'localNavCAN': {'__deferred': {...}},
865
+ 'localNavNOR': {'__deferred': {...}},
866
+ 'localNavOMN': {'__deferred': {...}},
867
+ 'localNavPER': {'__deferred': {...}},
868
+ 'localNavSGP': {'__deferred': {...}},
869
+ 'localNavVEN': {'__deferred': {...}},
870
+ 'localNavZAF': {'__deferred': {...}},
871
+ 'localNavCHL': {'__deferred': {...}},
872
+ 'localNavCHE': {'__deferred': {...}},
873
+ 'localNavDNK': {'__deferred': {...}},
874
+ 'localNavGTM': {'__deferred': {...}},
875
+ 'localNavNZL': {'__deferred': {...}},
876
+ 'salutationNav': {'__deferred': {...}},
877
+ 'localNavCHN': {'__deferred': {...}},
878
+ 'localNavVNM': {'__deferred': {...}},
879
+ 'localNavIDN': {'__deferred': {...}},
880
+ 'localNavPRT': {'__deferred': {...}},
881
+ 'localNavCOL': {'__deferred': {...}},
882
+ 'localNavHUN': {'__deferred': {...}},
883
+ 'localNavSWE': {'__deferred': {...}},
884
+ 'localNavESP': {'__deferred': {...}},
885
+ 'localNavUSA': {'__deferred': {...}},
886
+ 'nativePreferredLangNav': {'__deferred': {...}},
887
+ 'maritalStatusNav': {'__deferred': {...}}, ...}
888
+ """
889
+
890
+ if not self._access_token:
891
+ self.authenticate()
892
+
893
+ # Add query parameters (these are NOT passed via JSon body!)
894
+ query = {}
895
+ if field_name and field_value:
896
+ query["$filter"] = "{} {} {}".format(
897
+ field_name, field_operation, field_value
898
+ )
899
+ if max_results > 0:
900
+ query["$top"] = max_results
901
+ encoded_query = urllib.parse.urlencode(query, doseq=True)
902
+
903
+ request_url = self.config()["asUrl"] + entity
904
+ if query:
905
+ request_url += "?" + encoded_query
906
+
907
+ request_header = self.request_header()
908
+
909
+ response = requests.get(
910
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
911
+ )
912
+ if response.status_code == 200:
913
+ return self.parse_request_response(response)
914
+ else:
915
+ logger.error(
916
+ "Failed to retrieve employee data; status -> %s; error -> %s",
917
+ response.status_code,
918
+ response.text,
919
+ )
920
+ return None
921
+
922
+ # end method definition
923
+
924
+ def get_entities_metadata(self, entities: list | None = None) -> dict | None:
925
+ """Get the schema (metadata) for a list of entities (list can be empty to get it for all)
926
+ IMPORTANT: A metadata request using $metadata returns an XML serialization of the service,
927
+ including the entity data model (EDM) and the service operation descriptions.
928
+ The metadata response supports only application/xml type.
929
+
930
+ Args:
931
+ entities (list): list of entities to deliver metadata for
932
+
933
+ Returns:
934
+ dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.
935
+ """
936
+
937
+ if not self._access_token:
938
+ self.authenticate()
939
+
940
+ request_url = self.config()["asUrl"]
941
+ if entities:
942
+ request_url += "{}/".format(",".join(entities))
943
+ request_url += "$metadata"
944
+
945
+ request_header = self.request_header()
946
+ request_header["Accept"] = "application/xml"
947
+
948
+ response = requests.get(
949
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
950
+ )
951
+ if response.status_code == 200:
952
+ return xmltodict.parse(response.text)
953
+ else:
954
+ logger.error(
955
+ "Failed to retrieve entity data; status -> %s; error -> %s",
956
+ response.status_code,
957
+ response.text,
958
+ )
959
+ return None
960
+
961
+ # end method definition
962
+
963
+ def get_entity_metadata(self, entity: str) -> dict | None:
964
+ """Get the schema (metadata) for an entity
965
+
966
+ Args:
967
+ entity (str): entity to deliver metadata for
968
+
969
+ Returns:
970
+ dict | None: Dictionary with the SuccessFactors object data or None in case the request failed.
971
+ """
972
+
973
+ if not self._access_token:
974
+ self.authenticate()
975
+
976
+ if not entity:
977
+ return None
978
+
979
+ request_url = self.config()["asUrl"] + "Entity('{}')?$format=JSON".format(
980
+ entity
981
+ )
982
+
983
+ request_header = self.request_header()
984
+
985
+ response = requests.get(
986
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
987
+ )
988
+ if response.status_code == 200:
989
+ return self.parse_request_response(response)
990
+ else:
991
+ logger.error(
992
+ "Failed to retrieve entity data; status -> %s; error -> %s",
993
+ response.status_code,
994
+ response.text,
995
+ )
996
+ return None
997
+
998
+ # end method definition
999
+
1000
+ def update_user_email(
1001
+ self,
1002
+ user_id: str, # this is NOT the username but really an ID like 106020
1003
+ email_address: str,
1004
+ email_type: int = 8448, # 8448
1005
+ ) -> dict:
1006
+ """Update user email.
1007
+ See: https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/d599f15995d348a1b45ba5603e2aba9b/7b3daeb3d77d491bb401345eede34bb5.html?locale=en-US
1008
+
1009
+ Args:
1010
+ user_id (str): ID of the user (e.g. 106020)
1011
+ email_address (str): new email address of user
1012
+ email_type (int): Type of the email. 8448 = Business
1013
+ Returns:
1014
+ dict: Request response or None if an error occured.
1015
+ """
1016
+
1017
+ if not self._access_token:
1018
+ self.authenticate()
1019
+
1020
+ request_url = self.config()["asUrl"] + "upsert"
1021
+
1022
+ update_data = {
1023
+ "__metadata": {
1024
+ "uri": "PerEmail(emailType='{}',personIdExternal='{}')".format(
1025
+ email_type, user_id
1026
+ ),
1027
+ "type": "SFOData.PerEmail",
1028
+ },
1029
+ "emailAddress": email_address,
1030
+ }
1031
+
1032
+ request_header = self.request_header()
1033
+
1034
+ response = requests.post(
1035
+ request_url,
1036
+ headers=request_header,
1037
+ json=update_data,
1038
+ timeout=REQUEST_TIMEOUT,
1039
+ )
1040
+ if response.ok:
1041
+ logger.debug(
1042
+ "Email of user with ID -> %s successfully updated to -> %s.",
1043
+ user_id,
1044
+ email_address,
1045
+ )
1046
+ return self.parse_request_response(response)
1047
+ else:
1048
+ logger.error(
1049
+ "Failed to set email of user with ID -> %s; status -> %s; error -> %s",
1050
+ user_id,
1051
+ response.status_code,
1052
+ response.text,
1053
+ )
1054
+ return None
1055
+
1056
+ # end method definition