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.
- pyxecm/__init__.py +6 -2
- pyxecm/avts.py +1492 -0
- pyxecm/coreshare.py +1075 -960
- pyxecm/customizer/__init__.py +16 -4
- pyxecm/customizer/__main__.py +58 -0
- pyxecm/customizer/api/__init__.py +5 -0
- pyxecm/customizer/api/__main__.py +6 -0
- pyxecm/customizer/api/app.py +914 -0
- pyxecm/customizer/api/auth.py +154 -0
- pyxecm/customizer/api/metrics.py +92 -0
- pyxecm/customizer/api/models.py +13 -0
- pyxecm/customizer/api/payload_list.py +865 -0
- pyxecm/customizer/api/settings.py +103 -0
- pyxecm/customizer/browser_automation.py +332 -139
- pyxecm/customizer/customizer.py +1075 -1057
- pyxecm/customizer/exceptions.py +35 -0
- pyxecm/customizer/guidewire.py +322 -0
- pyxecm/customizer/k8s.py +787 -338
- pyxecm/customizer/log.py +107 -0
- pyxecm/customizer/m365.py +3424 -2270
- pyxecm/customizer/nhc.py +1169 -0
- pyxecm/customizer/openapi.py +258 -0
- pyxecm/customizer/payload.py +18201 -7030
- pyxecm/customizer/pht.py +1047 -210
- pyxecm/customizer/salesforce.py +836 -727
- pyxecm/customizer/sap.py +58 -41
- pyxecm/customizer/servicenow.py +851 -383
- pyxecm/customizer/settings.py +442 -0
- pyxecm/customizer/successfactors.py +408 -346
- pyxecm/customizer/translate.py +83 -48
- pyxecm/helper/__init__.py +5 -2
- pyxecm/helper/assoc.py +98 -38
- pyxecm/helper/data.py +2482 -742
- pyxecm/helper/logadapter.py +27 -0
- pyxecm/helper/web.py +229 -101
- pyxecm/helper/xml.py +528 -172
- pyxecm/maintenance_page/__init__.py +5 -0
- pyxecm/maintenance_page/__main__.py +6 -0
- pyxecm/maintenance_page/app.py +51 -0
- pyxecm/maintenance_page/settings.py +28 -0
- pyxecm/maintenance_page/static/favicon.avif +0 -0
- pyxecm/maintenance_page/templates/maintenance.html +165 -0
- pyxecm/otac.py +234 -140
- pyxecm/otawp.py +2689 -0
- pyxecm/otcs.py +12344 -7547
- pyxecm/otds.py +3166 -2219
- pyxecm/otiv.py +36 -21
- pyxecm/otmm.py +1363 -296
- pyxecm/otpd.py +231 -127
- pyxecm-2.0.0.dist-info/METADATA +145 -0
- pyxecm-2.0.0.dist-info/RECORD +54 -0
- {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info}/WHEEL +1 -1
- pyxecm-1.5.dist-info/METADATA +0 -51
- pyxecm-1.5.dist-info/RECORD +0 -30
- {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {pyxecm-1.5.dist-info → pyxecm-2.0.0.dist-info}/top_level.txt +0 -0
pyxecm/customizer/servicenow.py
CHANGED
|
@@ -1,68 +1,54 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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
|
|
65
|
-
"""
|
|
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
|
-
|
|
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):
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
servicenow_config["
|
|
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
|
-
"""
|
|
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
|
|
157
|
+
except Exception:
|
|
147
158
|
thread_name = threading.current_thread().name
|
|
148
|
-
logger.error(
|
|
149
|
-
|
|
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
|
-
"""
|
|
167
|
+
"""Return the configuration dictionary.
|
|
155
168
|
|
|
156
169
|
Returns:
|
|
157
|
-
dict:
|
|
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:
|
|
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
|
-
"""
|
|
176
|
-
|
|
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):
|
|
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:
|
|
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
|
-
"""
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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):
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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,
|
|
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):
|
|
252
|
-
|
|
253
|
-
|
|
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:
|
|
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
|
|
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):
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
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
|
|
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
|
-
"""
|
|
396
|
+
"""Return the OAuth access token.
|
|
343
397
|
|
|
344
398
|
Returns:
|
|
345
|
-
str:
|
|
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):
|
|
385
|
-
|
|
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:
|
|
389
|
-
|
|
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,
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
419
|
-
logger.error(
|
|
420
|
-
"Request error occurred while resolving -> %s in table -> '%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
|
|
426
|
-
logger.error(
|
|
427
|
-
"An error occurred while resolving -> %s in table -> '%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):
|
|
499
|
+
summary_sys_id (str):
|
|
500
|
+
The system ID of the article.
|
|
442
501
|
|
|
443
502
|
Returns:
|
|
444
|
-
dict | None:
|
|
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
|
|
521
|
+
"""Retrieve a specified ServiceNow table data (row or values).
|
|
459
522
|
|
|
460
523
|
Args:
|
|
461
|
-
table_name (str):
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
|
511
|
-
logger.error("%sHTTP error
|
|
512
|
-
except RequestException
|
|
513
|
-
logger.error("%sRequest error
|
|
514
|
-
except Exception
|
|
515
|
-
logger.error("%
|
|
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
|
|
693
|
+
"""Get the configured knowledge bases in ServiceNow.
|
|
521
694
|
|
|
522
695
|
Returns:
|
|
523
|
-
list | None:
|
|
696
|
+
list | None:
|
|
697
|
+
The list of configured knowledge bases or None in case of an error.
|
|
524
698
|
|
|
525
|
-
|
|
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
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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:
|
|
787
|
+
list | None:
|
|
788
|
+
List or articles or None if the request fails.
|
|
651
789
|
|
|
652
|
-
|
|
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=
|
|
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.
|
|
771
|
-
|
|
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):
|
|
775
|
-
|
|
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
|
|
806
|
-
|
|
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):
|
|
814
|
-
|
|
951
|
+
article (dict):
|
|
952
|
+
Article information.
|
|
815
953
|
|
|
816
954
|
Returns:
|
|
817
|
-
|
|
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,
|
|
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
|
|
980
|
+
self.logger.debug(
|
|
981
|
+
"Knowledge base article -> %s does not have attachments!",
|
|
840
982
|
article_number,
|
|
841
983
|
)
|
|
842
|
-
|
|
843
|
-
return False
|
|
984
|
+
return []
|
|
844
985
|
else:
|
|
845
|
-
logger.
|
|
846
|
-
"Knowledge base article -> %s has %s attachments
|
|
986
|
+
self.logger.debug(
|
|
987
|
+
"Knowledge base article -> %s has %s attachments.",
|
|
847
988
|
article_number,
|
|
848
989
|
len(attachments),
|
|
849
990
|
)
|
|
850
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
1046
|
+
base_dir = os.path.join(self._download_dir, article_number)
|
|
857
1047
|
|
|
858
|
-
|
|
859
|
-
|
|
1048
|
+
# save download dir for later use in bulkDocument processing...
|
|
1049
|
+
article["download_dir"] = base_dir
|
|
860
1050
|
|
|
861
|
-
|
|
862
|
-
|
|
1051
|
+
article["download_files"] = []
|
|
1052
|
+
article["download_files_ids"] = []
|
|
863
1053
|
|
|
864
|
-
|
|
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
|
-
|
|
868
|
-
|
|
1072
|
+
for attachment in attachments:
|
|
1073
|
+
file_path = os.path.join(base_dir, attachment["file_name"])
|
|
869
1074
|
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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,
|
|
1099
|
+
attachment_download_url,
|
|
1100
|
+
stream=True,
|
|
888
1101
|
)
|
|
889
1102
|
attachment_response.raise_for_status()
|
|
890
1103
|
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
1107
|
+
attachment_file.write(chunk)
|
|
899
1108
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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(
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
925
|
-
|
|
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,
|
|
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
|
|
1216
|
+
"""Worker method for multi-threading.
|
|
975
1217
|
|
|
976
1218
|
Args:
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
"
|
|
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 =
|
|
1244
|
+
limit = min(partition_size, 100)
|
|
993
1245
|
|
|
994
1246
|
for offset in range(partition_offset, partition_offset + partition_size, limit):
|
|
995
|
-
articles = self.
|
|
996
|
-
|
|
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
|
-
|
|
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
|
|
1011
|
-
|
|
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):
|
|
1015
|
-
|
|
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,
|
|
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 =
|
|
1321
|
+
category_table_name = SN_TABLE_CATEGORIES
|
|
1029
1322
|
category = self.get_object(
|
|
1030
|
-
table_name=category_table_name,
|
|
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,
|
|
1328
|
+
response=category,
|
|
1329
|
+
key="full_category",
|
|
1035
1330
|
)
|
|
1036
1331
|
else:
|
|
1037
|
-
logger.warning(
|
|
1038
|
-
"Article -> %s has no category value!",
|
|
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.
|
|
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 =
|
|
1345
|
+
knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
|
|
1047
1346
|
knowledge_base = self.get_object(
|
|
1048
|
-
table_name=knowledge_base_table_name,
|
|
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,
|
|
1352
|
+
response=knowledge_base,
|
|
1353
|
+
key="title",
|
|
1053
1354
|
)
|
|
1054
1355
|
else:
|
|
1055
|
-
logger.warning(
|
|
1056
|
-
"Article -> %s has no value for
|
|
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
|
-
|
|
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=
|
|
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,
|
|
1373
|
+
response=related_product,
|
|
1374
|
+
key="name",
|
|
1072
1375
|
)
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1401
|
+
self.logger.warning(
|
|
1402
|
+
"Article -> %s: Cannot lookup related product name in table -> '%s' with key -> '%s'",
|
|
1089
1403
|
article["number"],
|
|
1090
|
-
|
|
1404
|
+
SN_TABLE_RELATED_PRODUCTS,
|
|
1091
1405
|
related_product_key,
|
|
1092
1406
|
)
|
|
1093
1407
|
else:
|
|
1094
|
-
logger.
|
|
1095
|
-
"Article -> %s has no
|
|
1408
|
+
self.logger.debug(
|
|
1409
|
+
"Article -> %s has no related products!",
|
|
1096
1410
|
article["number"],
|
|
1097
1411
|
)
|
|
1098
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
1428
|
+
response=product_line,
|
|
1429
|
+
key="name",
|
|
1111
1430
|
)
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1127
|
-
"Article -> %s: Cannot lookup related
|
|
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.
|
|
1134
|
-
"Article -> %s has no
|
|
1457
|
+
self.logger.debug(
|
|
1458
|
+
"Article -> %s has no related product lines!",
|
|
1135
1459
|
article["number"],
|
|
1136
1460
|
)
|
|
1137
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
1477
|
+
response=sub_product_line,
|
|
1478
|
+
key="name",
|
|
1150
1479
|
)
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1166
|
-
"Article -> %s: Cannot lookup related
|
|
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.
|
|
1173
|
-
"Article -> %s has no
|
|
1505
|
+
self.logger.debug(
|
|
1506
|
+
"Article -> %s has no related sub product lines!",
|
|
1174
1507
|
article["number"],
|
|
1175
1508
|
)
|
|
1176
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
1525
|
+
response=application,
|
|
1526
|
+
key="name",
|
|
1189
1527
|
)
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
1212
|
-
"Article -> %s has no
|
|
1561
|
+
self.logger.debug(
|
|
1562
|
+
"Article -> %s has no related applications!",
|
|
1213
1563
|
article["number"],
|
|
1214
1564
|
)
|
|
1215
|
-
|
|
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():
|