pyxecm 1.5__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (56) hide show
  1. pyxecm/__init__.py +6 -2
  2. pyxecm/avts.py +1492 -0
  3. pyxecm/coreshare.py +1075 -960
  4. pyxecm/customizer/__init__.py +16 -4
  5. pyxecm/customizer/__main__.py +58 -0
  6. pyxecm/customizer/api/__init__.py +5 -0
  7. pyxecm/customizer/api/__main__.py +6 -0
  8. pyxecm/customizer/api/app.py +914 -0
  9. pyxecm/customizer/api/auth.py +154 -0
  10. pyxecm/customizer/api/metrics.py +92 -0
  11. pyxecm/customizer/api/models.py +13 -0
  12. pyxecm/customizer/api/payload_list.py +865 -0
  13. pyxecm/customizer/api/settings.py +103 -0
  14. pyxecm/customizer/browser_automation.py +332 -139
  15. pyxecm/customizer/customizer.py +1075 -1057
  16. pyxecm/customizer/exceptions.py +35 -0
  17. pyxecm/customizer/guidewire.py +322 -0
  18. pyxecm/customizer/k8s.py +787 -338
  19. pyxecm/customizer/log.py +107 -0
  20. pyxecm/customizer/m365.py +3424 -2270
  21. pyxecm/customizer/nhc.py +1169 -0
  22. pyxecm/customizer/openapi.py +258 -0
  23. pyxecm/customizer/payload.py +18201 -7030
  24. pyxecm/customizer/pht.py +1047 -210
  25. pyxecm/customizer/salesforce.py +836 -727
  26. pyxecm/customizer/sap.py +58 -41
  27. pyxecm/customizer/servicenow.py +851 -383
  28. pyxecm/customizer/settings.py +442 -0
  29. pyxecm/customizer/successfactors.py +408 -346
  30. pyxecm/customizer/translate.py +83 -48
  31. pyxecm/helper/__init__.py +5 -2
  32. pyxecm/helper/assoc.py +98 -38
  33. pyxecm/helper/data.py +2482 -742
  34. pyxecm/helper/logadapter.py +27 -0
  35. pyxecm/helper/web.py +229 -101
  36. pyxecm/helper/xml.py +528 -172
  37. pyxecm/maintenance_page/__init__.py +5 -0
  38. pyxecm/maintenance_page/__main__.py +6 -0
  39. pyxecm/maintenance_page/app.py +51 -0
  40. pyxecm/maintenance_page/settings.py +28 -0
  41. pyxecm/maintenance_page/static/favicon.avif +0 -0
  42. pyxecm/maintenance_page/templates/maintenance.html +165 -0
  43. pyxecm/otac.py +234 -140
  44. pyxecm/otawp.py +2689 -0
  45. pyxecm/otcs.py +12344 -7547
  46. pyxecm/otds.py +3166 -2219
  47. pyxecm/otiv.py +36 -21
  48. pyxecm/otmm.py +1363 -296
  49. pyxecm/otpd.py +231 -127
  50. pyxecm-2.0.0.dist-info/METADATA +145 -0
  51. pyxecm-2.0.0.dist-info/RECORD +54 -0
  52. {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info}/WHEEL +1 -1
  53. pyxecm-1.5.dist-info/METADATA +0 -51
  54. pyxecm-1.5.dist-info/RECORD +0 -30
  55. {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info/licenses}/LICENSE +0 -0
  56. {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info}/top_level.txt +0 -0
@@ -1,68 +1,54 @@
1
- """
2
- ServiceNow Module to interact with the ServiceNow API
3
- See:
4
-
5
- Class: ServiceNow
6
- Methods:
7
-
8
- __init__ : class initializer
9
- config : Returns config data set
10
- credentials: Returns the token data
11
- request_header: Returns the request header for ServiceNow API calls
12
- parse_request_response: Parse the REST API responses and convert
13
- them to Python dict in a safe way
14
- exist_result_item: Check if an dict item is in the response
15
- of the ServiceNow API call
16
- get_result_value: Check if a defined value (based on a key) is in the ServiceNow API response
17
-
18
- authenticate : Authenticates at ServiceNow API
19
- get_oauth_token: Returns the OAuth access token.
20
-
21
- get_data: Get the Data object that holds all processed Knowledge base Articles
22
- get_object: Get an ServiceNow object based on table name and ID
23
- get_summary: Get summary object for an article.
24
- get_knowledge_base_articles: Get selected / filtered Knowledge Base articles
25
- make_file_names_unique: Make file names unique if required. The mutable
26
- list is changed "in-place".
27
- download_attachments: Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
28
- load_articles: Main method to load ServiceNow articles in a Data Frame and
29
- download the attchments.
30
- load_article: Process a single KBA: download attachments (if any)
31
- and add the KBA to the Data Frame.
32
- load_articles_worker: Worker Method for multi-threading.
1
+ """ServiceNow Module to interact with the ServiceNow API.
2
+
3
+ See: https://developer.servicenow.com
33
4
  """
34
5
 
35
6
  __author__ = "Dr. Marc Diefenbruch"
36
- __copyright__ = "Copyright 2024, OpenText"
7
+ __copyright__ = "Copyright (C) 2024-2025, OpenText"
37
8
  __credits__ = ["Kai-Philip Gatzweiler"]
38
9
  __maintainer__ = "Dr. Marc Diefenbruch"
39
10
  __email__ = "mdiefenb@opentext.com"
40
11
 
41
- import os
42
12
  import json
43
13
  import logging
44
- import urllib.parse
14
+ import os
15
+ import tempfile
45
16
  import threading
46
- import traceback
47
- from functools import cache
48
17
  import time
18
+ import urllib.parse
19
+ from collections.abc import Callable
20
+ from functools import cache
21
+ from typing import Any
49
22
 
50
23
  import requests
51
24
  from requests.auth import HTTPBasicAuth
52
25
  from requests.exceptions import HTTPError, RequestException
53
- from pyxecm.helper.data import Data
54
26
 
55
- logger = logging.getLogger("pyxecm.customizer.servicenow")
27
+ from pyxecm.helper import Data
28
+
29
+ default_logger = logging.getLogger("pyxecm.customizer.servicenow")
56
30
 
57
31
  REQUEST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
58
32
 
59
33
  REQUEST_TIMEOUT = 60
60
34
 
61
- KNOWLEDGE_BASE_PATH = "/tmp/attachments"
35
+ KNOWLEDGE_BASE_PATH = os.path.join(tempfile.gettempdir(), "attachments")
36
+
37
+ # ServiceNow database tables. Table names starting with "u_" are custom OpenText tables:
38
+ SN_TABLE_CATEGORIES = "kb_category"
39
+ SN_TABLE_KNOWLEDGE_BASES = "kb_knowledge_base"
40
+ SN_TABLE_KNOWLEDGE_BASE_ARTICLES = "u_kb_template_technical_article_public"
41
+ SN_TABLE_KNOWLEDGE_BASE_ARTICLES_PRODUCT = "u_kb_template_product_documentation_standard"
42
+ SN_TABLE_RELATED_PRODUCTS = "cmdb_model"
43
+ SN_TABLE_PRODUCT_LINES = "u_ot_product_model"
44
+ SN_TABLE_PRODUCT_VERSIONS = "u_ot_product_model_version"
45
+ SN_TABLE_ATTACHMENTS = "sys_attachment"
62
46
 
63
47
 
64
- class ServiceNow(object):
65
- """Used to retrieve and automate stettings in ServiceNow."""
48
+ class ServiceNow:
49
+ """Class used to retrieve and automate stettings in ServiceNow."""
50
+
51
+ logger: logging.Logger = default_logger
66
52
 
67
53
  _config: dict
68
54
  _access_token = None
@@ -70,6 +56,7 @@ class ServiceNow(object):
70
56
  _data: Data = None
71
57
  _thread_number = 3
72
58
  _download_dir = ""
59
+ _product_exclusions = None
73
60
 
74
61
  def __init__(
75
62
  self,
@@ -82,21 +69,42 @@ class ServiceNow(object):
82
69
  token_url: str = "",
83
70
  thread_number: int = 3,
84
71
  download_dir: str = KNOWLEDGE_BASE_PATH,
85
- ):
86
- """Initialize the Service Now object
72
+ product_exclusions: list | None = None,
73
+ logger: logging.Logger = default_logger,
74
+ ) -> None:
75
+ """Initialize the Service Now object.
87
76
 
88
77
  Args:
89
- base_url (str): base URL of the ServiceNow tenant
90
- auth_type (str): authorization type, either "oauth" or "basic"
91
- client_id (str): ServiceNow Client ID
92
- client_secret (str): ServiceNow Client Secret
93
- username (str): user name in Saleforce
94
- password (str): password of the user
95
- token_url (str, optional): Token URL for ServiceNow login via OAuth.
96
- thread_number (int, optional): number of threads for parallel processing. Default is 3.
97
- download_path (str): path to stored downloaded files from ServiceNow
78
+ base_url (str):
79
+ The base URL of the ServiceNow tenant.
80
+ auth_type (str):
81
+ Athe authorization type, either "oauth" or "basic".
82
+ client_id (str):
83
+ ServiceNow Client ID.
84
+ client_secret (str):
85
+ The ServiceNow client secret.
86
+ username (str):
87
+ The user name in ServiceNow.
88
+ password (str):
89
+ The password of the ServiceNow user.
90
+ token_url (str, optional):
91
+ Token URL for ServiceNow login via OAuth.
92
+ thread_number (int, optional):
93
+ The number of threads for parallel processing. Default is 3.
94
+ download_dir (str, optional):
95
+ The path to stored downloaded files from ServiceNow.
96
+ product_exclusions (list | None, optional):
97
+ List of products that should NOT be loaded from ServiceNow.
98
+ logger:
99
+ The logging object used for all log messages. Default is default_logger.
100
+
98
101
  """
99
102
 
103
+ if logger != default_logger:
104
+ self.logger = logger.getChild("servicenow")
105
+ for logfilter in logger.filters:
106
+ self.logger.addFilter(logfilter)
107
+
100
108
  servicenow_config = {}
101
109
 
102
110
  # Store the credentials and parameters in a config dictionary:
@@ -113,58 +121,68 @@ class ServiceNow(object):
113
121
 
114
122
  servicenow_config["restUrl"] = servicenow_config["baseUrl"] + "/api/now/"
115
123
  servicenow_config["tableUrl"] = servicenow_config["restUrl"] + "table"
116
- servicenow_config["knowledgeUrl"] = (
117
- servicenow_config["restUrl"] + "table/kb_knowledge"
118
- )
119
- servicenow_config["knowledgeBaseUrl"] = (
120
- servicenow_config["restUrl"] + "table/kb_knowledge_base"
121
- )
122
- servicenow_config["attachmentsUrl"] = (
123
- servicenow_config["restUrl"] + "table/sys_attachment"
124
- )
125
- servicenow_config["attachmentDownloadUrl"] = (
126
- servicenow_config["restUrl"] + "attachment"
127
- )
124
+ servicenow_config["knowledgeUrl"] = servicenow_config["restUrl"] + "table/kb_knowledge"
125
+ servicenow_config["knowledgeBaseUrl"] = servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
126
+ servicenow_config["attachmentsUrl"] = servicenow_config["restUrl"] + "table/" + SN_TABLE_ATTACHMENTS
127
+ servicenow_config["attachmentDownloadUrl"] = servicenow_config["restUrl"] + "attachment"
128
128
  servicenow_config["statsUrl"] = servicenow_config["restUrl"] + "stats"
129
129
 
130
130
  self._config = servicenow_config
131
131
 
132
132
  self._session = requests.Session()
133
133
 
134
- self._data = Data()
134
+ self._data = Data(logger=self.logger)
135
135
 
136
136
  self._thread_number = thread_number
137
-
138
137
  self._download_dir = download_dir
138
+ self._product_exclusions = product_exclusions
139
139
 
140
140
  # end method definition
141
141
 
142
- def thread_wrapper(self, target, *args, **kwargs):
143
- """Function to wrap around threads to catch exceptions during exection"""
142
+ def thread_wrapper(self, target: Callable, *args: tuple, **kwargs: dict[str, Any]) -> None:
143
+ """Wrap around threads to catch exceptions during exection.
144
+
145
+ Args:
146
+ target (Callable):
147
+ The method (callable) the Thread should run.
148
+ args (tuple):
149
+ The arguments for the method.
150
+ kwargs (dict):
151
+ Keyword arguments for the method.
152
+
153
+ """
154
+
144
155
  try:
145
156
  target(*args, **kwargs)
146
- except Exception as e:
157
+ except Exception:
147
158
  thread_name = threading.current_thread().name
148
- logger.error("Thread %s: failed with exception %s", thread_name, e)
149
- logger.error(traceback.format_exc())
159
+ self.logger.error(
160
+ "Thread '%s': failed!",
161
+ thread_name,
162
+ )
150
163
 
151
164
  # end method definition
152
165
 
153
166
  def config(self) -> dict:
154
- """Returns the configuration dictionary
167
+ """Return the configuration dictionary.
155
168
 
156
169
  Returns:
157
- dict: Configuration dictionary
170
+ dict:
171
+ The configuration dictionary.
172
+
158
173
  """
174
+
159
175
  return self._config
160
176
 
161
177
  # end method definition
162
178
 
163
179
  def get_data(self) -> Data:
164
- """Get the Data object that holds all processed Knowledge base Articles
180
+ """Get the Data object that holds all processed Knowledge base Articles.
165
181
 
166
182
  Returns:
167
- Data: Datastructure with all processed articles.
183
+ Data:
184
+ Data object (with embedded data frame) holding all processed articles.
185
+
168
186
  """
169
187
 
170
188
  return self._data
@@ -172,13 +190,22 @@ class ServiceNow(object):
172
190
  # end method definition
173
191
 
174
192
  def request_header(self, content_type: str = "") -> dict:
175
- """Returns the request header used for Application calls.
176
- Consists of Bearer access token and Content Type
193
+ """Return the request header used for Application calls.
194
+
195
+ Consists of Bearer access token and Content Type.
177
196
 
178
197
  Args:
179
- content_type (str, optional): custom content type for the request
198
+ content_type (str, optional):
199
+ Custom content type for the request.
200
+ Typical values:
201
+ * application/json - Used for sending JSON-encoded data
202
+ * application/x-www-form-urlencoded - The default for HTML forms.
203
+ Data is sent as key-value pairs in the body of the request, similar to query parameters.
204
+ * multipart/form-data - Used for file uploads or when a form includes non-ASCII characters
180
205
  Return:
181
- dict: request header values
206
+ dict:
207
+ The request header values.
208
+
182
209
  """
183
210
 
184
211
  request_header = {}
@@ -201,43 +228,48 @@ class ServiceNow(object):
201
228
  additional_error_message: str = "",
202
229
  show_error: bool = True,
203
230
  ) -> dict | None:
204
- """Converts the request response (JSon) to a Python dict in a safe way
205
- that also handles exceptions. It first tries to load the response.text
206
- via json.loads() that produces a dict output. Only if response.text is
207
- not set or is empty it just converts the response_object to a dict using
208
- the vars() built-in method.
231
+ """Convert the request response (JSon) to a Python dict in a safe way.
232
+
233
+ It handles exceptions and first tries to load the response.text
234
+ via json.loads() that produces a dict output. Only if response.text is
235
+ not set or is empty it just converts the response_object to a dict using
236
+ the vars() built-in method.
209
237
 
210
238
  Args:
211
- response_object (object): this is reponse object delivered by the request call
212
- additional_error_message (str, optional): use a more specific error message
213
- in case of an error
214
- show_error (bool): True: write an error to the log file
215
- False: write a warning to the log file
239
+ response_object (object):
240
+ This is reponse object delivered by the request call.
241
+ additional_error_message (str, optional):
242
+ If provided, use a more specific error message
243
+ in case of an error.
244
+ show_error (bool, optional):
245
+ True: write an error to the log file.
246
+ False: write a warning to the log file.
247
+
216
248
  Returns:
217
- dict: response information or None in case of an error
249
+ dict | None:
250
+ Response information or None in case of an error.
251
+
218
252
  """
219
253
 
220
254
  if not response_object:
221
255
  return None
222
256
 
223
257
  try:
224
- if response_object.text:
225
- dict_object = json.loads(response_object.text)
226
- else:
227
- dict_object = vars(response_object)
258
+ dict_object = json.loads(response_object.text) if response_object.text else vars(response_object)
228
259
  except json.JSONDecodeError as exception:
229
260
  if additional_error_message:
230
261
  message = "Cannot decode response as JSON. {}; error -> {}".format(
231
- additional_error_message, exception
262
+ additional_error_message,
263
+ exception,
232
264
  )
233
265
  else:
234
266
  message = "Cannot decode response as JSON; error -> {}".format(
235
- exception
267
+ exception,
236
268
  )
237
269
  if show_error:
238
- logger.error(message)
270
+ self.logger.error(message)
239
271
  else:
240
- logger.warning(message)
272
+ self.logger.warning(message)
241
273
  return None
242
274
  else:
243
275
  return dict_object
@@ -248,11 +280,17 @@ class ServiceNow(object):
248
280
  """Check existence of key / value pair in the response properties of an ServiceNow API call.
249
281
 
250
282
  Args:
251
- response (dict): REST response from an Salesforce API call
252
- key (str): property name (key)
253
- value (str): value to find in the item with the matching key
283
+ response (dict):
284
+ REST response from an ServiceNow API call.
285
+ key (str):
286
+ The property name (key) to check the value of.
287
+ value (str):
288
+ Value to find in the item with the matching key.
289
+
254
290
  Returns:
255
- bool: True if the value was found, False otherwise
291
+ bool:
292
+ True if the value was found, False otherwise.
293
+
256
294
  """
257
295
 
258
296
  if not response:
@@ -267,7 +305,7 @@ class ServiceNow(object):
267
305
  if value == record[key]:
268
306
  return True
269
307
  else:
270
- if not key in response:
308
+ if key not in response:
271
309
  return False
272
310
  if value == response[key]:
273
311
  return True
@@ -285,16 +323,22 @@ class ServiceNow(object):
285
323
  """Get value of a result property with a given key of an ServiceNow API call.
286
324
 
287
325
  Args:
288
- response (dict): REST response from an Salesforce REST Call
289
- key (str): property name (key)
290
- index (int, optional): Index to use (1st element has index 0).
291
- Defaults to 0.
326
+ response (dict):
327
+ REST response from an ServiceNow REST call.
328
+ key (str):
329
+ The property name (key) to get the value of.
330
+ index (int, optional):
331
+ Index to use (1st element has index 0).
332
+ Defaults to 0.
333
+
292
334
  Returns:
293
- str: value for the key, None otherwise
335
+ str:
336
+ The value for the key, None otherwise.
337
+
294
338
  """
295
339
 
296
340
  # ServiceNow responses should always have a "result":
297
- if not response or not "result" in response:
341
+ if not response or "result" not in response:
298
342
  return None
299
343
 
300
344
  values = response["result"]
@@ -308,7 +352,7 @@ class ServiceNow(object):
308
352
  elif isinstance(values, dict) and key in values:
309
353
  value = values[key]
310
354
  else:
311
- logger.error("Illegal data type in ServiceNow response!")
355
+ self.logger.error("Illegal data type in ServiceNow response!")
312
356
  return None
313
357
 
314
358
  return value
@@ -316,7 +360,17 @@ class ServiceNow(object):
316
360
  # end method definition
317
361
 
318
362
  def authenticate(self, auth_type: str) -> str | None:
319
- """Authenticate at ServiceNow with client ID and client secret or with basic authentication."""
363
+ """Authenticate at ServiceNow with client ID and client secret or with basic authentication.
364
+
365
+ Args:
366
+ auth_type (str):
367
+ The Authorization type. This can be "basic" or "oauth".
368
+
369
+ Returns:
370
+ str:
371
+ The session token or None in case of an error.
372
+
373
+ """
320
374
 
321
375
  self._session.headers.update(self.request_header())
322
376
 
@@ -333,16 +387,18 @@ class ServiceNow(object):
333
387
 
334
388
  return token
335
389
  else:
336
- logger.error("Unsupported authentication type")
390
+ self.logger.error("Unsupported authentication type")
337
391
  return None
338
392
 
339
393
  # end method definition
340
394
 
341
395
  def get_oauth_token(self) -> str:
342
- """Returns the OAuth access token.
396
+ """Return the OAuth access token.
343
397
 
344
398
  Returns:
345
- str: Access token
399
+ str:
400
+ The access token.
401
+
346
402
  """
347
403
 
348
404
  token_post_body = {
@@ -364,9 +420,9 @@ class ServiceNow(object):
364
420
  else:
365
421
  # Store authentication access_token:
366
422
  self._access_token = authenticate_dict["access_token"]
367
- logger.debug("Access Token -> %s", self._access_token)
423
+ self.logger.debug("Access Token -> %s", self._access_token)
368
424
  else:
369
- logger.error(
425
+ self.logger.error(
370
426
  "Failed to request an Service Now Access Token; error -> %s",
371
427
  response.text,
372
428
  )
@@ -378,57 +434,59 @@ class ServiceNow(object):
378
434
 
379
435
  @cache
380
436
  def get_object(self, table_name: str, sys_id: str) -> dict | None:
381
- """Get an ServiceNow object based on table name and ID
437
+ """Get an ServiceNow object based on table name and ID.
382
438
 
383
439
  Args:
384
- table_name (str): Name of the ServiceNow table.
385
- sys_id (str): ID of the data set to resolve.
440
+ table_name (str):
441
+ The name of the ServiceNow table.
442
+ sys_id (str):
443
+ The ID of the data set to resolve.
386
444
 
387
445
  Returns:
388
- dict | None: dictionary of fields of resulting table row or None
389
- in case an error occured.
446
+ dict | None:
447
+ The dictionary of fields of resulting table row or None
448
+ in case an error occured.
449
+
390
450
  """
391
451
 
392
452
  if not table_name:
393
- logger.error("Table name is missing!")
453
+ self.logger.error("Table name is missing!")
394
454
  return None
395
455
 
396
456
  if not sys_id:
397
- logger.error("System ID of item to lookup is missing!")
457
+ self.logger.error("System ID of item to lookup is missing!")
398
458
  return None
399
459
 
400
460
  request_header = self.request_header()
401
461
 
402
462
  request_url = self.config()["restUrl"] + "table/{}/{}".format(
403
- table_name, sys_id
463
+ table_name,
464
+ sys_id,
404
465
  )
405
466
 
406
467
  try:
407
468
  response = self._session.get(url=request_url, headers=request_header)
408
469
  data = self.parse_request_response(response)
409
-
410
- return data
411
- except HTTPError as http_err:
412
- logger.error(
413
- "HTTP error occurred while resolving -> %s in table -> '%s': %s",
470
+ except HTTPError:
471
+ self.logger.error(
472
+ "HTTP error occurred while resolving -> '%s' in table -> '%s'!",
414
473
  sys_id,
415
474
  table_name,
416
- str(http_err),
417
475
  )
418
- except RequestException as req_err:
419
- logger.error(
420
- "Request error occurred while resolving -> %s in table -> '%s': %s",
476
+ except RequestException:
477
+ self.logger.error(
478
+ "Request error occurred while resolving -> '%s' in table -> '%s'!",
421
479
  sys_id,
422
480
  table_name,
423
- str(req_err),
424
481
  )
425
- except Exception as err:
426
- logger.error(
427
- "An error occurred while resolving -> %s in table -> '%s': %s",
482
+ except Exception:
483
+ self.logger.error(
484
+ "An error occurred while resolving -> '%s' in table -> '%s'!",
428
485
  sys_id,
429
486
  table_name,
430
- str(err),
431
487
  )
488
+ else:
489
+ return data
432
490
 
433
491
  return None
434
492
 
@@ -438,14 +496,19 @@ class ServiceNow(object):
438
496
  """Get summary object for an article.
439
497
 
440
498
  Args:
441
- summary_sys_id (str): System ID of the article
499
+ summary_sys_id (str):
500
+ The system ID of the article.
442
501
 
443
502
  Returns:
444
- dict | None: _description_
503
+ dict | None:
504
+ The dictionary with the summary.
505
+
445
506
  """
446
507
 
447
508
  return self.get_object(table_name="kb_knowledge_summary", sys_id=summary_sys_id)
448
509
 
510
+ # end method definition
511
+
449
512
  def get_table(
450
513
  self,
451
514
  table_name: str,
@@ -455,20 +518,27 @@ class ServiceNow(object):
455
518
  offset: int = 0,
456
519
  error_string: str = "",
457
520
  ) -> list | None:
458
- """Retrieve a specified ServiceNow column.
521
+ """Retrieve a specified ServiceNow table data (row or values).
459
522
 
460
523
  Args:
461
- table_name (str): Name of the ServiceNow table
462
- query (str, optional): Query to filter the the articles.
463
- fields (list, optional): Just return the fileds in this list.
464
- Defaults to None which means to deliver
465
- all fields.
466
- limit (int, optional): Number of results to return. None = unlimited.
467
- offset (int, optional): first item to return (for chunking)
468
- error_string (str, optional): custom error string
524
+ table_name (str):
525
+ The name of the ServiceNow table to retrieve.
526
+ query (str, optional):
527
+ Query to filter the table rows (e.g. articles).
528
+ fields (list, optional):
529
+ Just return the fileds in this list.
530
+ Defaults to None which means to deliver all fields.
531
+ limit (int, optional):
532
+ Number of results to return. None = unlimited.
533
+ offset (int, optional):
534
+ First item to return (for chunking).
535
+ error_string (str, optional):
536
+ A custom error string can be provided by this parameter.
469
537
 
470
538
  Returns:
471
- list | None: List or articles or None if the request fails.
539
+ list | None:
540
+ List or articles or None if the request fails.
541
+
472
542
  """
473
543
 
474
544
  request_header = self.request_header()
@@ -487,42 +557,146 @@ class ServiceNow(object):
487
557
  encoded_query = urllib.parse.urlencode(params, doseq=True)
488
558
 
489
559
  request_url = self.config()["tableUrl"] + "/{}?{}".format(
490
- table_name, encoded_query
560
+ table_name,
561
+ encoded_query,
491
562
  )
492
563
 
493
564
  try:
494
565
  while True:
495
566
  response = self._session.get(
496
- url=request_url, headers=request_header # , params=params
567
+ url=request_url,
568
+ headers=request_header, # , params=params
497
569
  )
498
570
  data = self.parse_request_response(response)
499
571
 
500
572
  if response.status_code == 200:
501
573
  return data.get("result", [])
502
574
  elif response.status_code == 202:
503
- logger.warning(
504
- "Service Now returned <202 Accepted> -> throtteling, retrying ..."
575
+ self.logger.warning(
576
+ "Service Now returned <202 Accepted> -> throtteling, retrying ...",
505
577
  )
506
578
  time.sleep(1000)
507
579
  else:
508
580
  return None
509
581
 
510
- except HTTPError as http_err:
511
- logger.error("%sHTTP error -> %s!", error_string, str(http_err))
512
- except RequestException as req_err:
513
- logger.error("%sRequest error -> %s!", error_string, str(req_err))
514
- except Exception as err:
515
- logger.error("%sError -> %s!", error_string, str(err))
582
+ except HTTPError:
583
+ self.logger.error("%sHTTP error!", error_string)
584
+ except RequestException:
585
+ self.logger.error("%sRequest error!", error_string)
586
+ except Exception:
587
+ self.logger.error("%s", error_string)
516
588
 
517
589
  return None
518
590
 
591
+ # end method definition
592
+
593
+ def get_table_count(
594
+ self,
595
+ table_name: str,
596
+ query: str | None = None,
597
+ ) -> int:
598
+ """Get number of table rows (e.g. Knowledge Base Articles) matching the query.
599
+
600
+ (or if query = "" it should be the total number).
601
+
602
+ Args:
603
+ table_name (str):
604
+ The name of the ServiceNow table.
605
+ query (str, optional):
606
+ A query string to filter the results. Defaults to "".
607
+
608
+ Returns:
609
+ int:
610
+ Number of table rows.
611
+
612
+ """
613
+
614
+ request_header = self.request_header()
615
+
616
+ params = {"sysparm_count": "true"}
617
+
618
+ if query:
619
+ params["sysparm_query"] = query
620
+
621
+ encoded_query = urllib.parse.urlencode(params, doseq=True)
622
+
623
+ request_url = self.config()["statsUrl"] + "/{}?{}".format(
624
+ table_name,
625
+ encoded_query,
626
+ )
627
+
628
+ try:
629
+ response = self._session.get(
630
+ url=request_url,
631
+ headers=request_header,
632
+ timeout=600,
633
+ )
634
+ data = self.parse_request_response(response)
635
+ return int(data["result"]["stats"]["count"])
636
+ except HTTPError:
637
+ self.logger.error("HTTP error occurred!")
638
+ except RequestException:
639
+ self.logger.error("Request error occurred!")
640
+ except Exception:
641
+ self.logger.error("An error occurred!")
642
+
643
+ return None
644
+
645
+ # end method definition
646
+
647
+ def get_categories(self) -> list | None:
648
+ """Get the configured knowledge base categories in ServiceNow.
649
+
650
+ Returns:
651
+ list | None:
652
+ A list of configured knowledge base categories
653
+ or None in case of an error.
654
+
655
+ Example:
656
+ [
657
+ {
658
+ 'sys_mod_count': '2',
659
+ 'active': 'true',
660
+ 'full_category': 'Patch / Rollup/Set',
661
+ 'label': 'Rollup/Set',
662
+ 'sys_updated_on': '2022-04-04 16:33:57',
663
+ 'sys_domain_path': '/',
664
+ 'sys_tags': '',
665
+ 'parent_table': 'kb_category',
666
+ 'sys_id': '05915bc91b1ac9109b6987b7624bcbed',
667
+ 'sys_updated_by': 'vbalachandra@opentext.com',
668
+ 'parent_id': {
669
+ 'link': 'https://support-qa.opentext.com/api/now/table/kb_category/395093891b1ac9109b6987b7624bcb1b',
670
+ 'value': '395093891b1ac9109b6987b7624bcb1b'
671
+ },
672
+ 'sys_created_on': '2022-03-16 09:53:56',
673
+ 'sys_domain': {
674
+ 'link': 'https://support-qa.opentext.com/api/now/table/sys_user_group/global',
675
+ 'value': 'global'
676
+ },
677
+ 'value': 'rollup_set',
678
+ 'sys_created_by': 'tiychowdhury@opentext.com'
679
+ }
680
+ ]
681
+
682
+ """
683
+
684
+ return self.get_table(
685
+ table_name=SN_TABLE_CATEGORIES,
686
+ error_string="Cannot get Categories; ",
687
+ limit=50,
688
+ )
689
+
690
+ # end method definition
691
+
519
692
  def get_knowledge_bases(self) -> list | None:
520
- """Get the configured knowledge bases in Service Now.
693
+ """Get the configured knowledge bases in ServiceNow.
521
694
 
522
695
  Returns:
523
- list | None: list of configured knowledge bases or None in case of an error.
696
+ list | None:
697
+ The list of configured knowledge bases or None in case of an error.
524
698
 
525
- Example:
699
+ Example:
526
700
  [
527
701
  {
528
702
  'mandatory_fields': '',
@@ -572,84 +746,48 @@ class ServiceNow(object):
572
746
  'card_color': '',
573
747
  'disable_rating': 'false',
574
748
  'create_translation_task': 'false',
575
- 'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec,2a685f4c1be7811461a7a8e22a4bcbfd,6cc3c3d2db21781068cfd6c4e2961962,053429e31b5f0114fea2ec20604bcb95,5454eb441b6b0514fea2ec20604bcbfc,3a17970c1be7811461a7a8e22a4bcb23'
749
+ 'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec'
576
750
  },
577
751
  ...
578
752
  ]
579
- """
580
-
581
- return self.get_table(
582
- table_name="kb_knowledge_base", error_string="Cannot get Knowledge Bases; "
583
- )
584
753
 
585
- # end method definition
586
-
587
- def get_table_count(
588
- self,
589
- table_name: str,
590
- query: str | None = None,
591
- ) -> int:
592
- """Get number of Knowledge Base Articles matching the query (or if query = "" it should be the total number)
593
-
594
- Args:
595
- table_name (str): name of the ServiceNow table
596
- query (str, optional): Query string to filter the results. Defaults to "".
597
-
598
- Returns:
599
- int: Number of Knowledge Base Articles.
600
754
  """
601
755
 
602
- request_header = self.request_header()
603
-
604
- params = {"sysparm_count": "true"}
605
-
606
- if query:
607
- params["sysparm_query"] = query
608
-
609
- encoded_query = urllib.parse.urlencode(params, doseq=True)
610
-
611
- request_url = self.config()["statsUrl"] + "/{}?{}".format(
612
- table_name, encoded_query
756
+ return self.get_table(
757
+ table_name=SN_TABLE_KNOWLEDGE_BASES,
758
+ error_string="Cannot get Knowledge Bases; ",
613
759
  )
614
760
 
615
- try:
616
- response = self._session.get(
617
- url=request_url, headers=request_header, timeout=600
618
- )
619
- data = self.parse_request_response(response)
620
- return int(data["result"]["stats"]["count"])
621
- except HTTPError as http_err:
622
- logger.error("HTTP error occurred -> %s!", str(http_err))
623
- except RequestException as req_err:
624
- logger.error("Request error occurred -> %s!", str(req_err))
625
- except Exception as err:
626
- logger.error("An error occurred -> %s!", str(err))
627
-
628
- return None
629
-
630
761
  # end method definition
631
762
 
632
763
  def get_knowledge_base_articles(
633
764
  self,
765
+ table_name: str = SN_TABLE_KNOWLEDGE_BASE_ARTICLES,
634
766
  query: str = "",
635
767
  fields: list | None = None,
636
768
  limit: int | None = 10,
637
769
  offset: int = 0,
638
770
  ) -> list | None:
639
- """Get selected / filtered Knowledge Base articles
771
+ """Get selected / filtered Knowledge Base articles.
640
772
 
641
773
  Args:
642
- query (str, optional): Query to filter the the articles.
643
- fields (list, optional): Just return the fileds in this list.
644
- Defaults to None which means to deliver
645
- all fields.
646
- limit (int, optional): Number of results to return. None = unlimited.
647
- offset (int, optional): first item to return (for chunking)
774
+ table_name (str, optional):
775
+ The name of the ServiceNow table.
776
+ query (str, optional):
777
+ Query to filter the articles.
778
+ fields (list, optional):
779
+ Just return the fields in this list.
780
+ Defaults to None which means to deliver all fields.
781
+ limit (int, optional):
782
+ Number of results to return. None = unlimited.
783
+ offset (int, optional):
784
+ The first item to return (for chunking).
648
785
 
649
786
  Returns:
650
- list | None: List or articles or None if the request fails.
787
+ list | None:
788
+ List or articles or None if the request fails.
651
789
 
652
- Example:
790
+ Example:
653
791
  [
654
792
  {
655
793
  'parent': '',
@@ -753,10 +891,11 @@ class ServiceNow(object):
753
891
  },
754
892
  ...
755
893
  ]
894
+
756
895
  """
757
896
 
758
897
  return self.get_table(
759
- table_name="u_kb_template_technical_article_public", # derived from table kb_knowledge
898
+ table_name=table_name, # derived from table kb_knowledge
760
899
  query=query,
761
900
  fields=fields,
762
901
  limit=limit,
@@ -766,13 +905,16 @@ class ServiceNow(object):
766
905
 
767
906
  # end method definition
768
907
 
769
- def make_file_names_unique(self, file_list: list):
770
- """Make file names unique if required. The mutable
771
- list is changed "in-place".
908
+ def make_file_names_unique(self, file_list: list) -> None:
909
+ """Make file names unique if required.
910
+
911
+ The mutable list is changed "in-place".
772
912
 
773
913
  Args:
774
- file_list (list): list of attachments as dictionaries
775
- with "sys_id" and "file_name" keys.
914
+ file_list (list):
915
+ List of attachments as dictionaries
916
+ with "sys_id" and "file_name" keys.
917
+
776
918
  """
777
919
 
778
920
  # Dictionary to keep track of how many times each file name has been encountered
@@ -802,19 +944,17 @@ class ServiceNow(object):
802
944
 
803
945
  # end method definition
804
946
 
805
- def download_attachments(
806
- self,
807
- article: dict,
808
- skip_existing: bool = True,
809
- ) -> bool:
810
- """Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
947
+ def get_article_attachments(self, article: dict) -> list | None:
948
+ """Get a list of attachments for an article.
811
949
 
812
950
  Args:
813
- article (dict): dictionary holding the Service Now article data
814
- skip_existing (bool, optional): skip download if file has been downloaded before
951
+ article (dict):
952
+ Article information.
815
953
 
816
954
  Returns:
817
- bool: True = success, False = failure
955
+ list | None:
956
+ List of attachments for the article.
957
+
818
958
  """
819
959
 
820
960
  article_sys_id = article["sys_id"]
@@ -830,101 +970,195 @@ class ServiceNow(object):
830
970
 
831
971
  try:
832
972
  response = self._session.get(
833
- url=request_url, headers=request_header, params=params
973
+ url=request_url,
974
+ headers=request_header,
975
+ params=params,
834
976
  )
835
977
  data = self.parse_request_response(response)
836
978
  attachments = data.get("result", [])
837
979
  if not attachments:
838
- logger.debug(
839
- "Knowledge base article -> %s does not have attachments to download!",
980
+ self.logger.debug(
981
+ "Knowledge base article -> %s does not have attachments!",
840
982
  article_number,
841
983
  )
842
- article["has_attachments"] = False
843
- return False
984
+ return []
844
985
  else:
845
- logger.info(
846
- "Knowledge base article -> %s has %s attachments to download...",
986
+ self.logger.debug(
987
+ "Knowledge base article -> %s has %s attachments.",
847
988
  article_number,
848
989
  len(attachments),
849
990
  )
850
- article["has_attachments"] = True
991
+ return attachments
992
+
993
+ except HTTPError:
994
+ self.logger.error("HTTP error occurred!")
995
+ except RequestException:
996
+ self.logger.error("Request error occurred!")
997
+ except Exception:
998
+ self.logger.error("An error occurred!")
999
+
1000
+ return None
1001
+
1002
+ # end method definition
1003
+
1004
+ def download_attachments(
1005
+ self,
1006
+ article: dict,
1007
+ skip_existing: bool = True,
1008
+ ) -> bool:
1009
+ """Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
1010
+
1011
+ Args:
1012
+ article (dict):
1013
+ The dictionary holding the ServiceNow article data.
1014
+ skip_existing (bool, optional):
1015
+ If True, skip download if file has been downloaded before.
1016
+
1017
+ Returns:
1018
+ bool:
1019
+ True = success, False = failure.
1020
+
1021
+ """
851
1022
 
852
- # Service Now can have multiple files with the same name - we need to
853
- # resolve this for Extended ECM:
854
- self.make_file_names_unique(attachments)
1023
+ article_number = article["number"]
1024
+
1025
+ attachments = self.get_article_attachments(article=article)
1026
+
1027
+ if not attachments:
1028
+ self.logger.debug(
1029
+ "Knowledge base article -> %s does not have attachments to download!",
1030
+ article_number,
1031
+ )
1032
+ article["has_attachments"] = False
1033
+ return False
1034
+ else:
1035
+ self.logger.info(
1036
+ "Knowledge base article -> %s has %s attachments to download...",
1037
+ article_number,
1038
+ len(attachments),
1039
+ )
1040
+ article["has_attachments"] = True
1041
+
1042
+ # Service Now can have multiple files with the same name - we need to
1043
+ # resolve this for Extended ECM:
1044
+ self.make_file_names_unique(attachments)
855
1045
 
856
- base_dir = os.path.join(self._download_dir, article_number)
1046
+ base_dir = os.path.join(self._download_dir, article_number)
857
1047
 
858
- # save download dir for later use in bulkDocument processing...
859
- article["download_dir"] = base_dir
1048
+ # save download dir for later use in bulkDocument processing...
1049
+ article["download_dir"] = base_dir
860
1050
 
861
- article["download_files"] = []
862
- article["download_files_ids"] = []
1051
+ article["download_files"] = []
1052
+ article["download_files_ids"] = []
863
1053
 
864
- if not os.path.exists(base_dir):
1054
+ if not os.path.exists(base_dir):
1055
+ try:
865
1056
  os.makedirs(base_dir)
1057
+ except FileExistsError:
1058
+ self.logger.error(
1059
+ "Directory -> '%s' already exists. Race condition occurred.",
1060
+ base_dir,
1061
+ )
1062
+ except PermissionError:
1063
+ self.logger.error("Permission error with directory -> %s", base_dir)
1064
+ return False
1065
+ except OSError:
1066
+ self.logger.error("OS error with directory -> %s", base_dir)
1067
+ return False
1068
+ except TypeError:
1069
+ self.logger.error("Invalid path type -> %s", base_dir)
1070
+ return False
866
1071
 
867
- for attachment in attachments:
868
- file_path = os.path.join(base_dir, attachment["file_name"])
1072
+ for attachment in attachments:
1073
+ file_path = os.path.join(base_dir, attachment["file_name"])
869
1074
 
870
- # we build a list of filenames and ids.
871
- # the ids we want to use as nicknames later on
1075
+ if os.path.exists(file_path) and skip_existing:
1076
+ self.logger.info(
1077
+ "File -> '%s' has been downloaded before. Skipping download...",
1078
+ file_path,
1079
+ )
1080
+
1081
+ # We need to add file_name and sys_id in the list of files and and file IDs
1082
+ # for later use in bulkDocument processing...
1083
+ # This creates two new columns "download_files" and "download_files_ids"
1084
+ # in the data frame:
872
1085
  article["download_files"].append(attachment["file_name"])
873
1086
  article["download_files_ids"].append(attachment["sys_id"])
874
- if os.path.exists(file_path) and skip_existing:
875
- logger.info(
876
- "File -> %s has been downloaded before. Skipping download...",
877
- file_path,
878
- )
879
- continue
880
- attachment_download_url = (
881
- self.config()["attachmentDownloadUrl"]
882
- + "/"
883
- + attachment["sys_id"]
884
- + "/file"
1087
+ continue
1088
+ attachment_download_url = self.config()["attachmentDownloadUrl"] + "/" + attachment["sys_id"] + "/file"
1089
+ try:
1090
+ self.logger.info(
1091
+ "Downloading attachment file -> '%s' for article -> '%s' from ServiceNow...",
1092
+ file_path,
1093
+ article_number,
885
1094
  )
1095
+
1096
+ # Request the attachment as a stream from ServiceNow.
1097
+ # This initiates the download process...
886
1098
  attachment_response = self._session.get(
887
- attachment_download_url, stream=True
1099
+ attachment_download_url,
1100
+ stream=True,
888
1101
  )
889
1102
  attachment_response.raise_for_status()
890
1103
 
891
- logger.info(
892
- "Downloading attachment file -> '%s' for article -> %s from ServiceNow...",
893
- file_path,
894
- article_number,
895
- )
896
- with open(file_path, "wb") as file:
1104
+ # Read and write the attachment file in chunks:
1105
+ with open(file_path, "wb") as attachment_file:
897
1106
  for chunk in attachment_response.iter_content(chunk_size=8192):
898
- file.write(chunk)
1107
+ attachment_file.write(chunk)
899
1108
 
900
- return True
901
- except HTTPError as http_err:
902
- logger.error("HTTP error occurred -> %s!", str(http_err))
903
- except RequestException as req_err:
904
- logger.error("Request error occurred -> %s!", str(req_err))
905
- except Exception as err:
906
- logger.error("An error occurred -> %s!", str(err))
1109
+ # We build a list of filenames and IDs.
1110
+ # The IDs we want to use as nicknames later on.
1111
+ article["download_files"].append(attachment["file_name"])
1112
+ article["download_files_ids"].append(attachment["sys_id"])
907
1113
 
908
- return False
1114
+ except HTTPError:
1115
+ self.logger.error(
1116
+ "Failed to download -> '%s' using url -> %s",
1117
+ attachment["file_name"],
1118
+ attachment_download_url,
1119
+ )
1120
+
1121
+ return True
909
1122
 
910
1123
  # end method definition
911
1124
 
912
- def load_articles(self, table_name: str, query: str | None) -> bool:
913
- """Main method to load ServiceNow articles in a Data Frame and
914
- download the attchments.
1125
+ def load_articles(
1126
+ self,
1127
+ table_name: str,
1128
+ query: str | None,
1129
+ skip_existing_downloads: bool = True,
1130
+ ) -> bool:
1131
+ """Load ServiceNow articles in a data frame and download the attchments.
915
1132
 
916
1133
  Args:
917
- query (str): Filter criteria for the articles.
1134
+ table_name (str):
1135
+ The name of the ServiceNow table.
1136
+ query (str | None):
1137
+ Filter criteria for the articles.
1138
+ skip_existing_downloads (bool, optional):
1139
+ If True, it tries to optimize the processing by reusing
1140
+ existing downloads of attachments in the file system.
918
1141
 
919
1142
  Returns:
920
- bool: True = Success, False = Failure
1143
+ bool:
1144
+ True = Success, False = Failure.
1145
+
921
1146
  """
922
1147
 
923
1148
  total_count = self.get_table_count(table_name=table_name, query=query)
924
- logger.info(
925
- "Total number of Knowledge Base Articles (KBA) -> %s", str(total_count)
1149
+
1150
+ self.logger.info(
1151
+ "Total number of Knowledge Base Articles (KBA) -> %s",
1152
+ str(total_count),
926
1153
  )
927
1154
 
1155
+ if total_count == 0:
1156
+ self.logger.info(
1157
+ "Query does not return any value from ServiceNow table -> '%s'. Finishing.",
1158
+ table_name,
1159
+ )
1160
+ return True
1161
+
928
1162
  number = self._thread_number
929
1163
 
930
1164
  if total_count >= number:
@@ -935,9 +1169,10 @@ class ServiceNow(object):
935
1169
  remainder = 0
936
1170
  number = 1
937
1171
 
938
- logger.info(
939
- "Processing -> %s Knowledge Base Articles (KBA), thread number -> %s, partition size -> %s",
1172
+ self.logger.info(
1173
+ "Processing -> %s Knowledge Base Articles (KBA), table name -> '%s', thread number -> %s, partition size -> %s",
940
1174
  str(total_count),
1175
+ table_name,
941
1176
  number,
942
1177
  partition_size,
943
1178
  )
@@ -948,13 +1183,15 @@ class ServiceNow(object):
948
1183
  for i in range(number):
949
1184
  current_partition_size = partition_size + (1 if i < remainder else 0)
950
1185
  thread = threading.Thread(
951
- name=f"load_articles_{i+1:02}",
1186
+ name=f"load_articles_{i + 1:02}",
952
1187
  target=self.thread_wrapper,
953
1188
  args=(
954
1189
  self.load_articles_worker,
1190
+ table_name,
955
1191
  query,
956
1192
  current_partition_size,
957
1193
  current_offset,
1194
+ skip_existing_downloads,
958
1195
  ),
959
1196
  )
960
1197
  thread.start()
@@ -969,54 +1206,110 @@ class ServiceNow(object):
969
1206
  # end method definition
970
1207
 
971
1208
  def load_articles_worker(
972
- self, query: str, partition_size: int, partition_offset: int
1209
+ self,
1210
+ table_name: str,
1211
+ query: str,
1212
+ partition_size: int,
1213
+ partition_offset: int,
1214
+ skip_existing_downloads: bool = True,
973
1215
  ) -> None:
974
- """Worker Method for multi-threading.
1216
+ """Worker method for multi-threading.
975
1217
 
976
1218
  Args:
977
- query (str): Query to select the relevant KBA.
978
- partition_size (int): Total size of the partition assigned to this thread.
979
- partition_offset (int): Starting offset for the KBAs this thread is processing.
1219
+ table_name (str):
1220
+ Name of the ServiceNow table.
1221
+ query (str):
1222
+ Query to select the relevant KBA.
1223
+ partition_size (int):
1224
+ Total size of the partition assigned to this thread.
1225
+ partition_offset (int):
1226
+ Starting offset for the KBAs this thread is processing.
1227
+ skip_existing_downloads (bool, optional):
1228
+ If True, it tries to optimize the processing by reusing
1229
+ existing downloads of attachments in the file system.
1230
+
980
1231
  """
981
1232
 
982
- logger.info(
983
- "Processing KBAs in range from -> %s to -> %s...",
1233
+ self.logger.info(
1234
+ "Start processing KBAs in range from -> %s to -> %s from table -> '%s'...",
984
1235
  partition_offset,
985
1236
  partition_offset + partition_size,
1237
+ table_name,
986
1238
  )
987
1239
 
988
1240
  # We cannot retrieve all KBAs in one go if the partition size is too big (> 100)
989
1241
  # So we define "limit" as the maximum number of KBAs we want to retrieve for one REST call.
990
1242
  # This should be a reasonable number to avoid timeouts. We also need to make sure
991
1243
  # the limit is not bigger than the the partition size:
992
- limit = 100 if partition_size > 100 else partition_size
1244
+ limit = min(partition_size, 100)
993
1245
 
994
1246
  for offset in range(partition_offset, partition_offset + partition_size, limit):
995
- articles = self.get_knowledge_base_articles(
996
- query=query, limit=limit, offset=offset
1247
+ articles = self.get_table(
1248
+ table_name=table_name,
1249
+ query=query,
1250
+ limit=limit,
1251
+ offset=offset,
997
1252
  )
998
- logger.info(
1253
+ self.logger.info(
999
1254
  "Retrieved a list of %s KBAs starting at offset -> %s to process.",
1000
1255
  str(len(articles)),
1001
1256
  offset,
1002
1257
  )
1003
1258
  for article in articles:
1004
- logger.info("Processing KBA -> %s...", article["number"])
1005
- self.load_article(article)
1259
+ self.logger.info("Processing KBA -> %s...", article["number"])
1260
+ article["source_table"] = table_name
1261
+ self.load_article(
1262
+ article=article,
1263
+ skip_existing_downloads=skip_existing_downloads,
1264
+ )
1265
+
1266
+ self.logger.info(
1267
+ "Finished processing KBAs in range from -> %s to -> %s from table -> '%s'.",
1268
+ partition_offset,
1269
+ partition_offset + partition_size,
1270
+ table_name,
1271
+ )
1006
1272
 
1007
1273
  # end method definition
1008
1274
 
1009
- def load_article(self, article: dict, skip_existing_downloads: bool = True):
1010
- """Process a single KBA: download attachments (if any)
1011
- and add the KBA to the Data Frame.
1275
+ def load_article(self, article: dict, skip_existing_downloads: bool = True) -> None:
1276
+ """Process a single KBA.
1277
+
1278
+ Download attachments (if any), add additional keys / values to the article from
1279
+ other ServiceNow tables, and finally add the KBA to the data frame.
1012
1280
 
1013
1281
  Args:
1014
- article (dict): Dictionary inclusing all fields of
1015
- a single KBA.
1282
+ article (dict):
1283
+ Dictionary inclusing all fields of a single KBA.
1284
+ This is a mutable variable that gets modified by this method!
1285
+ skip_existing_downloads (bool, optional):
1286
+ If True it tries to optimize the processing by reusing
1287
+ existing downloads of attachments.
1288
+
1289
+ Side effect:
1290
+ The article dict is modified with by adding additional key / value
1291
+ pairs (these can be used in the payload files!):
1292
+
1293
+ * kb_category_name - the readable name of the ServiceNow category
1294
+ * kb_knowledge_base_name - the readable name of the ServiceNow KnowledgeBase
1295
+ * related_product_names - this list includes the related product names for the article
1296
+ * u_product_line_names - this list includes the related product line names for the article
1297
+ * u_sub_product_line_names - this list includes the related sub product line names for the article
1298
+ * u_application_names - this list includes the related application names for the article
1299
+ * u_application_versions - this list includes the related application versions for the article
1300
+ * u_application_version_sets - this table includes lines for each application + version. Sub items:
1301
+ - u_product_model - name of the application
1302
+ - u_version_name - name of the version - e.g. 24.4
1303
+
1016
1304
  """
1017
1305
 
1306
+ #
1307
+ # Download the attachments of the KBA:
1308
+ #
1309
+
1018
1310
  _ = self.download_attachments(
1019
- article=article, skip_existing=skip_existing_downloads
1311
+ article=article,
1312
+ skip_existing=skip_existing_downloads,
1020
1313
  )
1021
1314
 
1022
1315
  #
@@ -1025,194 +1318,369 @@ class ServiceNow(object):
1025
1318
 
1026
1319
  if article.get("kb_category"):
1027
1320
  category_key = article.get("kb_category")["value"]
1028
- category_table_name = "kb_category"
1321
+ category_table_name = SN_TABLE_CATEGORIES
1029
1322
  category = self.get_object(
1030
- table_name=category_table_name, sys_id=category_key
1323
+ table_name=category_table_name,
1324
+ sys_id=category_key,
1031
1325
  )
1032
1326
  if category:
1033
1327
  article["kb_category_name"] = self.get_result_value(
1034
- response=category, key="full_category"
1328
+ response=category,
1329
+ key="full_category",
1035
1330
  )
1036
1331
  else:
1037
- logger.warning(
1038
- "Article -> %s has no category value!", article["number"]
1332
+ self.logger.warning(
1333
+ "Article -> %s has no category value!",
1334
+ article["number"],
1039
1335
  )
1040
1336
  article["kb_category_name"] = ""
1041
1337
  else:
1042
- logger.error("Article -> %s has no value for category!", article["number"])
1338
+ self.logger.warning(
1339
+ "Article -> %s has no value for category!",
1340
+ article["number"],
1341
+ )
1043
1342
  article["kb_category_name"] = ""
1044
1343
 
1045
1344
  knowledge_base_key = article.get("kb_knowledge_base")["value"]
1046
- knowledge_base_table_name = "kb_knowledge_base"
1345
+ knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
1047
1346
  knowledge_base = self.get_object(
1048
- table_name=knowledge_base_table_name, sys_id=knowledge_base_key
1347
+ table_name=knowledge_base_table_name,
1348
+ sys_id=knowledge_base_key,
1049
1349
  )
1050
1350
  if knowledge_base:
1051
1351
  article["kb_knowledge_base_name"] = self.get_result_value(
1052
- response=knowledge_base, key="title"
1352
+ response=knowledge_base,
1353
+ key="title",
1053
1354
  )
1054
1355
  else:
1055
- logger.warning(
1056
- "Article -> %s has no value for Knowledge Base!",
1356
+ self.logger.warning(
1357
+ "Article -> %s has no value for knowledge base!",
1057
1358
  article["number"],
1058
1359
  )
1059
1360
  article["kb_knowledge_base_name"] = ""
1060
1361
 
1061
- related_product_names = []
1362
+ # We use a set to make sure the resulting related items are unique:
1363
+ related_product_names: set = set()
1062
1364
  if article.get("related_products"):
1063
1365
  related_product_keys = article.get("related_products").split(",")
1064
- related_product_table = "cmdb_model"
1065
1366
  for related_product_key in related_product_keys:
1066
1367
  related_product = self.get_object(
1067
- table_name=related_product_table, sys_id=related_product_key
1368
+ table_name=SN_TABLE_RELATED_PRODUCTS,
1369
+ sys_id=related_product_key,
1068
1370
  )
1069
1371
  if related_product:
1070
1372
  related_product_name = self.get_result_value(
1071
- response=related_product, key="name"
1373
+ response=related_product,
1374
+ key="name",
1072
1375
  )
1073
- logger.debug(
1074
- "Found related Product -> '%s' (%s)",
1376
+ # Remove leading or trailing spaces (simple cleansing effort):
1377
+ related_product_name = related_product_name.strip() if related_product_name else ""
1378
+ if self._product_exclusions and related_product_name in self._product_exclusions:
1379
+ self.logger.info(
1380
+ "Found related product -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1381
+ related_product_name,
1382
+ related_product_key,
1383
+ )
1384
+ continue
1385
+ self.logger.debug(
1386
+ "Found related product -> '%s' (%s)",
1075
1387
  related_product_name,
1076
1388
  related_product_key,
1077
1389
  )
1078
- related_product_names.append(related_product_name)
1390
+ # Add the related item to the resulting set
1391
+ # (duplicates will not be added as it is a set):
1392
+ related_product_names.add(related_product_name)
1079
1393
  # Extended ECM can only handle a maxiumum of 50 line items:
1080
1394
  if len(related_product_names) == 49:
1081
- logger.info(
1082
- "Reached maximum of 50 multi-value items for related Products of article -> %s",
1395
+ self.logger.info(
1396
+ "Reached maximum of 50 multi-value items for related products of article -> %s",
1083
1397
  article["number"],
1084
1398
  )
1085
1399
  break
1086
1400
  else:
1087
- logger.warning(
1088
- "Article -> %s: Cannot lookup related Product name in table -> '%s' with ID -> %s",
1401
+ self.logger.warning(
1402
+ "Article -> %s: Cannot lookup related product name in table -> '%s' with key -> '%s'",
1089
1403
  article["number"],
1090
- related_product_table,
1404
+ SN_TABLE_RELATED_PRODUCTS,
1091
1405
  related_product_key,
1092
1406
  )
1093
1407
  else:
1094
- logger.warning(
1095
- "Article -> %s has no value related Products!",
1408
+ self.logger.debug(
1409
+ "Article -> %s has no related products!",
1096
1410
  article["number"],
1097
1411
  )
1098
- article["related_product_names"] = related_product_names
1412
+ # This adds a column to the data frame with the name "related_product_names"
1413
+ # (we convert the set to a list):
1414
+ article["related_product_names"] = list(related_product_names)
1099
1415
 
1100
- product_line_names = []
1101
- if article.get("u_product_line", None):
1416
+ # We use a set to make sure the resulting related items are unique:
1417
+ product_line_names: set = set()
1418
+ if article.get("u_product_line"):
1102
1419
  product_line_keys = article.get("u_product_line").split(",")
1103
- product_line_table = "u_ot_product_model"
1420
+ product_line_table = SN_TABLE_PRODUCT_LINES
1104
1421
  for product_line_key in product_line_keys:
1105
1422
  product_line = self.get_object(
1106
- table_name=product_line_table, sys_id=product_line_key
1423
+ table_name=product_line_table,
1424
+ sys_id=product_line_key,
1107
1425
  )
1108
1426
  if product_line:
1109
1427
  product_line_name = self.get_result_value(
1110
- response=product_line, key="name"
1428
+ response=product_line,
1429
+ key="name",
1111
1430
  )
1112
- logger.debug(
1113
- "Found related Product Line -> '%s' (%s)",
1431
+ # Remove leading or trailing spaces (simple cleansing effort):
1432
+ product_line_name = product_line_name.strip() if product_line_name else ""
1433
+ self.logger.debug(
1434
+ "Found related product line -> '%s' (%s)",
1114
1435
  product_line_name,
1115
1436
  product_line_key,
1116
1437
  )
1117
- product_line_names.append(product_line_name)
1438
+ # Add the related item to the resulting set
1439
+ # (duplicates will not be added as it is a set):
1440
+ product_line_names.add(product_line_name)
1118
1441
  # Extended ECM can only handle a maxiumum of 50 line items:
1119
1442
  if len(product_line_names) == 49:
1120
- logger.info(
1121
- "Reached maximum of 50 multi-value items for related Product Lines of article -> %s",
1443
+ self.logger.info(
1444
+ "Reached maximum of 50 multi-value items for related product lines of article -> %s",
1122
1445
  article["number"],
1123
1446
  )
1124
1447
  break
1448
+ # end if product_line:
1125
1449
  else:
1126
- logger.error(
1127
- "Article -> %s: Cannot lookup related Product Line name in table -> '%s' with ID -> %s",
1450
+ self.logger.warning(
1451
+ "Article -> %s: Cannot lookup related product line name in table -> '%s' with key -> '%s'",
1128
1452
  article["number"],
1129
1453
  product_line_table,
1130
1454
  product_line_key,
1131
1455
  )
1132
1456
  else:
1133
- logger.warning(
1134
- "Article -> %s has no value for related Product Lines!",
1457
+ self.logger.debug(
1458
+ "Article -> %s has no related product lines!",
1135
1459
  article["number"],
1136
1460
  )
1137
- article["u_product_line_names"] = product_line_names
1461
+ # This adds a column to the data frame with the name "u_product_line_names"
1462
+ # (we convert the set to a list):
1463
+ article["u_product_line_names"] = list(product_line_names)
1138
1464
 
1139
- sub_product_line_names = []
1140
- if article.get("u_sub_product_line", None):
1465
+ # We use a set to make sure the resulting related items are unique:
1466
+ sub_product_line_names: set = set()
1467
+ if article.get("u_sub_product_line"):
1141
1468
  sub_product_line_keys = article.get("u_sub_product_line").split(",")
1142
- sub_product_line_table = "u_ot_product_model"
1469
+ sub_product_line_table = SN_TABLE_PRODUCT_LINES
1143
1470
  for sub_product_line_key in sub_product_line_keys:
1144
1471
  sub_product_line = self.get_object(
1145
- table_name=sub_product_line_table, sys_id=sub_product_line_key
1472
+ table_name=sub_product_line_table,
1473
+ sys_id=sub_product_line_key,
1146
1474
  )
1147
1475
  if sub_product_line:
1148
1476
  sub_product_line_name = self.get_result_value(
1149
- response=sub_product_line, key="name"
1477
+ response=sub_product_line,
1478
+ key="name",
1150
1479
  )
1151
- logger.debug(
1152
- "Found related Sub Product Line -> '%s' (%s)",
1480
+ # Remove leading or trailing spaces (simple cleansing effort):
1481
+ sub_product_line_name = sub_product_line_name.strip() if sub_product_line_name else ""
1482
+ self.logger.debug(
1483
+ "Found related sub product line -> '%s' (%s)",
1153
1484
  sub_product_line_name,
1154
1485
  sub_product_line_key,
1155
1486
  )
1156
- sub_product_line_names.append(sub_product_line_name)
1487
+ # Add the related item to the resulting set
1488
+ # (duplicates will not be added as it is a set):
1489
+ sub_product_line_names.add(sub_product_line_name)
1157
1490
  # Extended ECM can only handle a maxiumum of 50 line items:
1158
1491
  if len(sub_product_line_names) == 49:
1159
- logger.info(
1160
- "Reached maximum of 50 multi-value items for related Sub Product Lines of article -> %s",
1492
+ self.logger.info(
1493
+ "Reached maximum of 50 multi-value items for related sub product lines of article -> %s",
1161
1494
  article["number"],
1162
1495
  )
1163
1496
  break
1164
1497
  else:
1165
- logger.error(
1166
- "Article -> %s: Cannot lookup related Sub Product Line name in table -> '%s' with ID -> %s",
1498
+ self.logger.warning(
1499
+ "Article -> %s: Cannot lookup related sub product line name in table -> '%s' with key -> '%s'",
1167
1500
  article["number"],
1168
1501
  sub_product_line_table,
1169
1502
  sub_product_line_key,
1170
1503
  )
1171
1504
  else:
1172
- logger.warning(
1173
- "Article -> %s has no value for related Sub Product Lines!",
1505
+ self.logger.debug(
1506
+ "Article -> %s has no related sub product lines!",
1174
1507
  article["number"],
1175
1508
  )
1176
- article["u_sub_product_line_names"] = sub_product_line_names
1509
+ # This adds a column to the data frame with the name "u_sub_product_line_names"
1510
+ # (we convert the set to a list):
1511
+ article["u_sub_product_line_names"] = list(sub_product_line_names)
1177
1512
 
1178
- application_names = []
1179
- if article.get("u_application", None):
1513
+ # We use a set to make sure the resulting related items are unique:
1514
+ application_names: set = set()
1515
+ if article.get("u_application"):
1180
1516
  application_keys = article.get("u_application").split(",")
1181
- application_table_name = "u_ot_product_model"
1517
+ application_table_name = SN_TABLE_PRODUCT_LINES
1182
1518
  for application_key in application_keys:
1183
1519
  application = self.get_object(
1184
- table_name=application_table_name, sys_id=application_key
1520
+ table_name=application_table_name,
1521
+ sys_id=application_key,
1185
1522
  )
1186
1523
  if application:
1187
1524
  application_name = self.get_result_value(
1188
- response=application, key="name"
1525
+ response=application,
1526
+ key="name",
1189
1527
  )
1190
- logger.debug(
1191
- "Found related Application -> '%s' (%s)",
1528
+ # Remove leading or trailing spaces (simple cleansing effort):
1529
+ application_name = application_name.strip() if application_name else ""
1530
+ if self._product_exclusions and application_name in self._product_exclusions:
1531
+ self.logger.info(
1532
+ "Found related application -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1533
+ application_name,
1534
+ application_key,
1535
+ )
1536
+ continue
1537
+ self.logger.debug(
1538
+ "Found related application -> '%s' (%s)",
1192
1539
  application_name,
1193
1540
  application_key,
1194
1541
  )
1195
- application_names.append(application_name)
1542
+ # Add the related item to the resulting set
1543
+ # (duplicates will not be added as it is a set):
1544
+ application_names.add(application_name)
1196
1545
  # Extended ECM can only handle a maxiumum of 50 line items:
1197
1546
  if len(application_names) == 49:
1198
- logger.info(
1199
- "Reached maximum of 50 multi-value items for related Applications of article -> %s",
1547
+ self.logger.info(
1548
+ "Reached maximum of 50 multi-value items for related applications of article -> %s",
1200
1549
  article["number"],
1201
1550
  )
1202
1551
  break
1552
+ # end if application
1203
1553
  else:
1204
- logger.warning(
1205
- "Article -> %s: Cannot lookup related Application name in table -> '%s' with ID -> %s",
1554
+ self.logger.warning(
1555
+ "Article -> %s: Cannot lookup related application name in table -> '%s' with key -> %s",
1206
1556
  article["number"],
1207
1557
  application_table_name,
1208
1558
  application_key,
1209
1559
  )
1210
1560
  else:
1211
- logger.warning(
1212
- "Article -> %s has no value for related Applications!",
1561
+ self.logger.debug(
1562
+ "Article -> %s has no related applications!",
1213
1563
  article["number"],
1214
1564
  )
1215
- article["u_application_names"] = application_names
1565
+ # This adds a column to the data frame with the name "u_application_names"
1566
+ # (we convert the set to a list):
1567
+ article["u_application_names"] = list(application_names)
1568
+
1569
+ application_versions: set = set()
1570
+ application_version_sets = []
1571
+ if article.get("u_application_version"):
1572
+ application_version_keys = article.get("u_application_version").split(",")
1573
+ for application_version_key in application_version_keys:
1574
+ # Get the version object from ServiceNow. It includes both,
1575
+ # the application version number and the application name:
1576
+ application_version = self.get_object(
1577
+ table_name=SN_TABLE_PRODUCT_VERSIONS,
1578
+ sys_id=application_version_key,
1579
+ )
1580
+ if application_version:
1581
+ application_version_name = self.get_result_value(
1582
+ response=application_version,
1583
+ key="u_version_name",
1584
+ )
1585
+ self.logger.debug(
1586
+ "Found related application version -> '%s' in table -> '%s' with key -> '%s'",
1587
+ application_version_name,
1588
+ SN_TABLE_PRODUCT_LINES,
1589
+ application_version_key,
1590
+ )
1591
+
1592
+ # Add the related version to the resulting set
1593
+ # (duplicates will not be added as it is a set):
1594
+ application_versions.add(application_version_name)
1595
+
1596
+ # Use the application key to lookup application name
1597
+ # for the version and fill a set
1598
+ application_key = self.get_result_value(
1599
+ response=application_version,
1600
+ key="u_product_model",
1601
+ )
1602
+
1603
+ if application_key:
1604
+ """
1605
+ u_application_model has a substructure like this:
1606
+ {
1607
+ 'link': 'https://support.opentext.com/api/now/table/u_ot_product_model/9b2dcea747f6d910ab0a9ed7536d4364',
1608
+ 'value': '9b2dcea747f6d910ab0a9ed7536d4364'
1609
+ }
1610
+ """
1611
+ # We want the value which represents the key to lookup the application name:
1612
+ application_key = application_key.get("value")
1613
+
1614
+ if application_key:
1615
+ # Retrieve the application with the application key from ServiceNBow:
1616
+ application = self.get_object(
1617
+ table_name=SN_TABLE_PRODUCT_LINES,
1618
+ sys_id=application_key,
1619
+ )
1620
+ application_name = self.get_result_value(
1621
+ response=application,
1622
+ key="name",
1623
+ )
1624
+ # Remove leading or trailing spaces (simple cleansing effort):
1625
+ application_name = application_name.strip() if application_name else ""
1626
+
1627
+ # We check if the application name is in the product exclusions list.
1628
+ # If this is the case we skip it from being added to the Application Version Set
1629
+ # as we don't want to create a workspace relationship.
1630
+ if (
1631
+ self._product_exclusions
1632
+ and application_name
1633
+ and application_name in self._product_exclusions
1634
+ ):
1635
+ self.logger.info(
1636
+ "Found related application -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1637
+ application_name,
1638
+ application_key,
1639
+ )
1640
+ continue
1641
+ self.logger.debug(
1642
+ "Found related application -> '%s' for version -> '%s' in table -> '%s' with key -> '%s'",
1643
+ application_name,
1644
+ application_version_name,
1645
+ SN_TABLE_PRODUCT_LINES,
1646
+ application_key,
1647
+ )
1648
+
1649
+ if application_name:
1650
+ application_version_sets.append(
1651
+ {
1652
+ "u_product_model": application_name,
1653
+ "u_version_name": application_version_name,
1654
+ },
1655
+ )
1656
+ # end if application_key
1657
+
1658
+ # Extended ECM can only handle a maxiumum of 50 line items:
1659
+ if len(application_version_sets) == 49:
1660
+ self.logger.info(
1661
+ "Reached maximum of 50 multi-value items for related application versions of article -> %s",
1662
+ article["number"],
1663
+ )
1664
+ break
1665
+ # end if application_version
1666
+ else:
1667
+ self.logger.warning(
1668
+ "Article -> %s: Cannot lookup related application version in table -> '%s' with key -> '%s'",
1669
+ article["number"],
1670
+ SN_TABLE_PRODUCT_VERSIONS,
1671
+ application_version_key,
1672
+ )
1673
+ else:
1674
+ self.logger.debug(
1675
+ "Article -> %s has no related application version!",
1676
+ article["number"],
1677
+ )
1678
+ # This adds a column to the data frame with the name "u_application_versions"
1679
+ # (we convert the set to a list):
1680
+ article["u_application_versions"] = list(application_versions)
1681
+
1682
+ # This list of dictionaries maps the applications and the versions (table-like structure)
1683
+ article["u_application_version_sets"] = application_version_sets
1216
1684
 
1217
1685
  # Now we add the article to the Pandas Data Frame in the Data class:
1218
1686
  with self._data.lock():