pyxecm 1.4__py3-none-any.whl → 1.6__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 +5 -0
- pyxecm/avts.py +1065 -0
- pyxecm/coreshare.py +2532 -0
- pyxecm/customizer/__init__.py +4 -0
- pyxecm/customizer/browser_automation.py +164 -54
- pyxecm/customizer/customizer.py +588 -231
- pyxecm/customizer/k8s.py +143 -29
- pyxecm/customizer/m365.py +1434 -1323
- pyxecm/customizer/payload.py +15073 -5933
- pyxecm/customizer/pht.py +926 -0
- pyxecm/customizer/salesforce.py +866 -351
- pyxecm/customizer/sap.py +4 -4
- pyxecm/customizer/servicenow.py +1467 -0
- pyxecm/customizer/successfactors.py +1056 -0
- pyxecm/helper/__init__.py +2 -0
- pyxecm/helper/assoc.py +44 -1
- pyxecm/helper/data.py +1731 -0
- pyxecm/helper/web.py +170 -46
- pyxecm/helper/xml.py +170 -34
- pyxecm/otac.py +309 -23
- pyxecm/otawp.py +1810 -0
- pyxecm/otcs.py +5308 -2985
- pyxecm/otds.py +1909 -1954
- pyxecm/otmm.py +928 -0
- pyxecm/otpd.py +13 -10
- {pyxecm-1.4.dist-info → pyxecm-1.6.dist-info}/METADATA +5 -1
- pyxecm-1.6.dist-info/RECORD +32 -0
- {pyxecm-1.4.dist-info → pyxecm-1.6.dist-info}/WHEEL +1 -1
- pyxecm-1.4.dist-info/RECORD +0 -24
- {pyxecm-1.4.dist-info → pyxecm-1.6.dist-info}/LICENSE +0 -0
- {pyxecm-1.4.dist-info → pyxecm-1.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1467 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ServiceNow Module to interact with the ServiceNow API
|
|
3
|
+
See:
|
|
4
|
+
|
|
5
|
+
Class: ServiceNow
|
|
6
|
+
Methods:
|
|
7
|
+
|
|
8
|
+
__init__ : class initializer
|
|
9
|
+
thread_wrapper: Function to wrap around threads to catch exceptions during exection
|
|
10
|
+
config : Returns the configuration dictionary
|
|
11
|
+
get_data: Get the Data object that holds all processed Knowledge base Articles (Pandas Data Frame)
|
|
12
|
+
request_header: Returns the request header for ServiceNow API calls
|
|
13
|
+
parse_request_response: Parse the REST API responses and convert
|
|
14
|
+
them to Python dict in a safe way
|
|
15
|
+
exist_result_item: Check if an dict item is in the response
|
|
16
|
+
of the ServiceNow API call
|
|
17
|
+
get_result_value: Check if a defined value (based on a key) is in the ServiceNow API response
|
|
18
|
+
|
|
19
|
+
authenticate : Authenticates at ServiceNow API
|
|
20
|
+
get_oauth_token: Returns the OAuth access token.
|
|
21
|
+
|
|
22
|
+
get_object: Get an ServiceNow object based on table name and ID
|
|
23
|
+
get_summary: Get summary object for an article.
|
|
24
|
+
get_table: Retrieve a specified ServiceNow table data (row or values)
|
|
25
|
+
get_table_count: Get number of table rows (e.g. Knowledge Base Articles) matching the query
|
|
26
|
+
(or if query = "" it should be the total number)
|
|
27
|
+
get_knowledge_bases: Get the configured knowledge bases in ServiceNow
|
|
28
|
+
get_knowledge_base_articles: Get selected / filtered Knowledge Base articles
|
|
29
|
+
make_file_names_unique: Make file names unique if required. The mutable
|
|
30
|
+
list is changed "in-place".
|
|
31
|
+
download_attachments: Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
|
|
32
|
+
load_articles: Main method to load ServiceNow articles in a Data Frame and
|
|
33
|
+
download the attchments.
|
|
34
|
+
load_articles_worker: Worker Method for multi-threading.
|
|
35
|
+
load_article: Process a single KBA: download attachments (if any)
|
|
36
|
+
and add the KBA to the Data Frame.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__author__ = "Dr. Marc Diefenbruch"
|
|
40
|
+
__copyright__ = "Copyright 2024, OpenText"
|
|
41
|
+
__credits__ = ["Kai-Philip Gatzweiler"]
|
|
42
|
+
__maintainer__ = "Dr. Marc Diefenbruch"
|
|
43
|
+
__email__ = "mdiefenb@opentext.com"
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
48
|
+
import urllib.parse
|
|
49
|
+
import threading
|
|
50
|
+
import traceback
|
|
51
|
+
from functools import cache
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
import requests
|
|
55
|
+
from requests.auth import HTTPBasicAuth
|
|
56
|
+
from requests.exceptions import HTTPError, RequestException
|
|
57
|
+
from pyxecm.helper.data import Data
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger("pyxecm.customizer.servicenow")
|
|
60
|
+
|
|
61
|
+
REQUEST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
62
|
+
|
|
63
|
+
REQUEST_TIMEOUT = 60
|
|
64
|
+
|
|
65
|
+
KNOWLEDGE_BASE_PATH = "/tmp/attachments"
|
|
66
|
+
|
|
67
|
+
# ServiceNow database tables. Table names starting with "u_" are custom OpenText tables:
|
|
68
|
+
SN_TABLE_CATEGORIES = "kb_category"
|
|
69
|
+
SN_TABLE_KNOWLEDGE_BASES = "kb_knowledge_base"
|
|
70
|
+
SN_TABLE_KNOWLEDGE_BASE_ARTICLES = "u_kb_template_technical_article_public"
|
|
71
|
+
SN_TABLE_KNOWLEDGE_BASE_ARTICLES_PRODUCT = (
|
|
72
|
+
"u_kb_template_product_documentation_standard"
|
|
73
|
+
)
|
|
74
|
+
SN_TABLE_RELATED_PRODUCTS = "cmdb_model"
|
|
75
|
+
SN_TABLE_PRODUCT_LINES = "u_ot_product_model"
|
|
76
|
+
SN_TABLE_PRODUCT_VERSIONS = "u_ot_product_model_version"
|
|
77
|
+
SN_TABLE_ATTACHMENTS = "sys_attachment"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ServiceNow(object):
|
|
81
|
+
"""Used to retrieve and automate stettings in ServiceNow."""
|
|
82
|
+
|
|
83
|
+
_config: dict
|
|
84
|
+
_access_token = None
|
|
85
|
+
_session = None
|
|
86
|
+
_data: Data = None
|
|
87
|
+
_thread_number = 3
|
|
88
|
+
_download_dir = ""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
base_url: str,
|
|
93
|
+
auth_type: str,
|
|
94
|
+
client_id: str,
|
|
95
|
+
client_secret: str,
|
|
96
|
+
username: str,
|
|
97
|
+
password: str,
|
|
98
|
+
token_url: str = "",
|
|
99
|
+
thread_number: int = 3,
|
|
100
|
+
download_dir: str = KNOWLEDGE_BASE_PATH,
|
|
101
|
+
):
|
|
102
|
+
"""Initialize the Service Now object
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
base_url (str): base URL of the ServiceNow tenant
|
|
106
|
+
auth_type (str): authorization type, either "oauth" or "basic"
|
|
107
|
+
client_id (str): ServiceNow Client ID
|
|
108
|
+
client_secret (str): ServiceNow Client Secret
|
|
109
|
+
username (str): user name in Saleforce
|
|
110
|
+
password (str): password of the user
|
|
111
|
+
token_url (str, optional): Token URL for ServiceNow login via OAuth.
|
|
112
|
+
thread_number (int, optional): number of threads for parallel processing. Default is 3.
|
|
113
|
+
download_path (str): path to stored downloaded files from ServiceNow
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
servicenow_config = {}
|
|
117
|
+
|
|
118
|
+
# Store the credentials and parameters in a config dictionary:
|
|
119
|
+
servicenow_config["baseUrl"] = base_url
|
|
120
|
+
servicenow_config["authType"] = auth_type
|
|
121
|
+
servicenow_config["clientId"] = client_id
|
|
122
|
+
servicenow_config["clientSecret"] = client_secret
|
|
123
|
+
servicenow_config["username"] = username
|
|
124
|
+
servicenow_config["password"] = password
|
|
125
|
+
if not token_url:
|
|
126
|
+
token_url = base_url + "/oauth_token.do"
|
|
127
|
+
else:
|
|
128
|
+
servicenow_config["tokenUrl"] = token_url
|
|
129
|
+
|
|
130
|
+
servicenow_config["restUrl"] = servicenow_config["baseUrl"] + "/api/now/"
|
|
131
|
+
servicenow_config["tableUrl"] = servicenow_config["restUrl"] + "table"
|
|
132
|
+
servicenow_config["knowledgeUrl"] = (
|
|
133
|
+
servicenow_config["restUrl"] + "table/kb_knowledge"
|
|
134
|
+
)
|
|
135
|
+
servicenow_config["knowledgeBaseUrl"] = (
|
|
136
|
+
servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
|
|
137
|
+
)
|
|
138
|
+
servicenow_config["attachmentsUrl"] = (
|
|
139
|
+
servicenow_config["restUrl"] + "table/" + SN_TABLE_ATTACHMENTS
|
|
140
|
+
)
|
|
141
|
+
servicenow_config["attachmentDownloadUrl"] = (
|
|
142
|
+
servicenow_config["restUrl"] + "attachment"
|
|
143
|
+
)
|
|
144
|
+
servicenow_config["statsUrl"] = servicenow_config["restUrl"] + "stats"
|
|
145
|
+
|
|
146
|
+
self._config = servicenow_config
|
|
147
|
+
|
|
148
|
+
self._session = requests.Session()
|
|
149
|
+
|
|
150
|
+
self._data = Data()
|
|
151
|
+
|
|
152
|
+
self._thread_number = thread_number
|
|
153
|
+
|
|
154
|
+
self._download_dir = download_dir
|
|
155
|
+
|
|
156
|
+
# end method definition
|
|
157
|
+
|
|
158
|
+
def thread_wrapper(self, target, *args, **kwargs):
|
|
159
|
+
"""Function to wrap around threads to catch exceptions during exection"""
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
target(*args, **kwargs)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
thread_name = threading.current_thread().name
|
|
165
|
+
logger.error(
|
|
166
|
+
"Thread '%s': failed with exception -> %s", thread_name, str(e)
|
|
167
|
+
)
|
|
168
|
+
logger.error(traceback.format_exc())
|
|
169
|
+
|
|
170
|
+
# end method definition
|
|
171
|
+
|
|
172
|
+
def config(self) -> dict:
|
|
173
|
+
"""Returns the configuration dictionary
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
dict: Configuration dictionary
|
|
177
|
+
"""
|
|
178
|
+
return self._config
|
|
179
|
+
|
|
180
|
+
# end method definition
|
|
181
|
+
|
|
182
|
+
def get_data(self) -> Data:
|
|
183
|
+
"""Get the Data object that holds all processed Knowledge base Articles
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Data: Datastructure with all processed articles.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
return self._data
|
|
190
|
+
|
|
191
|
+
# end method definition
|
|
192
|
+
|
|
193
|
+
def request_header(self, content_type: str = "") -> dict:
|
|
194
|
+
"""Returns the request header used for Application calls.
|
|
195
|
+
Consists of Bearer access token and Content Type
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content_type (str, optional): custom content type for the request.
|
|
199
|
+
Typical values:
|
|
200
|
+
* application/json - Used for sending JSON-encoded data
|
|
201
|
+
* application/x-www-form-urlencoded - The default for HTML forms. Data is sent as key-value pairs in the body of the request, similar to query parameters
|
|
202
|
+
* multipart/form-data - Used for file uploads or when a form includes non-ASCII characters
|
|
203
|
+
Return:
|
|
204
|
+
dict: request header values
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
request_header = {}
|
|
208
|
+
|
|
209
|
+
request_header = REQUEST_HEADERS
|
|
210
|
+
|
|
211
|
+
if self.config()["authType"] == "oauth":
|
|
212
|
+
request_header["Authorization"] = ("Bearer {}".format(self._access_token),)
|
|
213
|
+
|
|
214
|
+
if content_type:
|
|
215
|
+
request_header["Content-Type"] = content_type
|
|
216
|
+
|
|
217
|
+
return request_header
|
|
218
|
+
|
|
219
|
+
# end method definition
|
|
220
|
+
|
|
221
|
+
def parse_request_response(
|
|
222
|
+
self,
|
|
223
|
+
response_object: requests.Response,
|
|
224
|
+
additional_error_message: str = "",
|
|
225
|
+
show_error: bool = True,
|
|
226
|
+
) -> dict | None:
|
|
227
|
+
"""Converts the request response (JSon) to a Python dict in a safe way
|
|
228
|
+
that also handles exceptions. It first tries to load the response.text
|
|
229
|
+
via json.loads() that produces a dict output. Only if response.text is
|
|
230
|
+
not set or is empty it just converts the response_object to a dict using
|
|
231
|
+
the vars() built-in method.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
response_object (object): this is reponse object delivered by the request call
|
|
235
|
+
additional_error_message (str, optional): use a more specific error message
|
|
236
|
+
in case of an error
|
|
237
|
+
show_error (bool): True: write an error to the log file
|
|
238
|
+
False: write a warning to the log file
|
|
239
|
+
Returns:
|
|
240
|
+
dict: response information or None in case of an error
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if not response_object:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
if response_object.text:
|
|
248
|
+
dict_object = json.loads(response_object.text)
|
|
249
|
+
else:
|
|
250
|
+
dict_object = vars(response_object)
|
|
251
|
+
except json.JSONDecodeError as exception:
|
|
252
|
+
if additional_error_message:
|
|
253
|
+
message = "Cannot decode response as JSON. {}; error -> {}".format(
|
|
254
|
+
additional_error_message, exception
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
message = "Cannot decode response as JSON; error -> {}".format(
|
|
258
|
+
exception
|
|
259
|
+
)
|
|
260
|
+
if show_error:
|
|
261
|
+
logger.error(message)
|
|
262
|
+
else:
|
|
263
|
+
logger.warning(message)
|
|
264
|
+
return None
|
|
265
|
+
else:
|
|
266
|
+
return dict_object
|
|
267
|
+
|
|
268
|
+
# end method definition
|
|
269
|
+
|
|
270
|
+
def exist_result_item(self, response: dict, key: str, value: str) -> bool:
|
|
271
|
+
"""Check existence of key / value pair in the response properties of an ServiceNow API call.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
response (dict): REST response from an Salesforce API call
|
|
275
|
+
key (str): property name (key)
|
|
276
|
+
value (str): value to find in the item with the matching key
|
|
277
|
+
Returns:
|
|
278
|
+
bool: True if the value was found, False otherwise
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
if not response:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
if "result" in response:
|
|
285
|
+
records = response["result"]
|
|
286
|
+
if not records or not isinstance(records, list):
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
for record in records:
|
|
290
|
+
if value == record[key]:
|
|
291
|
+
return True
|
|
292
|
+
else:
|
|
293
|
+
if not key in response:
|
|
294
|
+
return False
|
|
295
|
+
if value == response[key]:
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
# end method definition
|
|
301
|
+
|
|
302
|
+
def get_result_value(
|
|
303
|
+
self,
|
|
304
|
+
response: dict,
|
|
305
|
+
key: str,
|
|
306
|
+
index: int = 0,
|
|
307
|
+
) -> str | None:
|
|
308
|
+
"""Get value of a result property with a given key of an ServiceNow API call.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
response (dict): REST response from an Salesforce REST Call
|
|
312
|
+
key (str): property name (key)
|
|
313
|
+
index (int, optional): Index to use (1st element has index 0).
|
|
314
|
+
Defaults to 0.
|
|
315
|
+
Returns:
|
|
316
|
+
str: value for the key, None otherwise
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
# ServiceNow responses should always have a "result":
|
|
320
|
+
if not response or not "result" in response:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
values = response["result"]
|
|
324
|
+
if not values:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
# Service now can either have a dict or a list structure
|
|
328
|
+
# in "results":
|
|
329
|
+
if isinstance(values, list) and len(values) - 1 < index:
|
|
330
|
+
value = values[index][key]
|
|
331
|
+
elif isinstance(values, dict) and key in values:
|
|
332
|
+
value = values[key]
|
|
333
|
+
else:
|
|
334
|
+
logger.error("Illegal data type in ServiceNow response!")
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
return value
|
|
338
|
+
|
|
339
|
+
# end method definition
|
|
340
|
+
|
|
341
|
+
def authenticate(self, auth_type: str) -> str | None:
|
|
342
|
+
"""Authenticate at ServiceNow with client ID and client secret or with basic authentication.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
auth_type (str): this can be "basic" or "oauth"
|
|
346
|
+
Returns:
|
|
347
|
+
str: session token or None in case of an error
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
self._session.headers.update(self.request_header())
|
|
352
|
+
|
|
353
|
+
if auth_type == "basic":
|
|
354
|
+
username = self.config()["username"]
|
|
355
|
+
password = self.config()["password"]
|
|
356
|
+
if not self._session:
|
|
357
|
+
self._session = requests.Session()
|
|
358
|
+
self._session.auth = HTTPBasicAuth(username, password)
|
|
359
|
+
return self._session.auth
|
|
360
|
+
elif auth_type == "oauth":
|
|
361
|
+
token = self.get_oauth_token()
|
|
362
|
+
self._session.headers.update({"Authorization": "Bearer {}".format(token)})
|
|
363
|
+
|
|
364
|
+
return token
|
|
365
|
+
else:
|
|
366
|
+
logger.error("Unsupported authentication type")
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
# end method definition
|
|
370
|
+
|
|
371
|
+
def get_oauth_token(self) -> str:
|
|
372
|
+
"""Returns the OAuth access token.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
str: Access token
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
token_post_body = {
|
|
379
|
+
"grant_type": "client_credentials",
|
|
380
|
+
"client_id": self.config()["client_id"],
|
|
381
|
+
"client_secret": self.config()["client_secret"],
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
response = requests.post(
|
|
385
|
+
url=self.config()["token_url"],
|
|
386
|
+
data=token_post_body,
|
|
387
|
+
timeout=REQUEST_TIMEOUT,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if response.ok:
|
|
391
|
+
authenticate_dict = self.parse_request_response(response)
|
|
392
|
+
if not authenticate_dict:
|
|
393
|
+
return None
|
|
394
|
+
else:
|
|
395
|
+
# Store authentication access_token:
|
|
396
|
+
self._access_token = authenticate_dict["access_token"]
|
|
397
|
+
logger.debug("Access Token -> %s", self._access_token)
|
|
398
|
+
else:
|
|
399
|
+
logger.error(
|
|
400
|
+
"Failed to request an Service Now Access Token; error -> %s",
|
|
401
|
+
response.text,
|
|
402
|
+
)
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
return self._access_token
|
|
406
|
+
|
|
407
|
+
# end method definition
|
|
408
|
+
|
|
409
|
+
@cache
|
|
410
|
+
def get_object(self, table_name: str, sys_id: str) -> dict | None:
|
|
411
|
+
"""Get an ServiceNow object based on table name and ID
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
table_name (str): Name of the ServiceNow table.
|
|
415
|
+
sys_id (str): ID of the data set to resolve.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
dict | None: dictionary of fields of resulting table row or None
|
|
419
|
+
in case an error occured.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
if not table_name:
|
|
423
|
+
logger.error("Table name is missing!")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
if not sys_id:
|
|
427
|
+
logger.error("System ID of item to lookup is missing!")
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
request_header = self.request_header()
|
|
431
|
+
|
|
432
|
+
request_url = self.config()["restUrl"] + "table/{}/{}".format(
|
|
433
|
+
table_name, sys_id
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
response = self._session.get(url=request_url, headers=request_header)
|
|
438
|
+
data = self.parse_request_response(response)
|
|
439
|
+
|
|
440
|
+
return data
|
|
441
|
+
except HTTPError as http_err:
|
|
442
|
+
logger.error(
|
|
443
|
+
"HTTP error occurred while resolving -> %s in table -> '%s': %s",
|
|
444
|
+
sys_id,
|
|
445
|
+
table_name,
|
|
446
|
+
str(http_err),
|
|
447
|
+
)
|
|
448
|
+
except RequestException as req_err:
|
|
449
|
+
logger.error(
|
|
450
|
+
"Request error occurred while resolving -> %s in table -> '%s': %s",
|
|
451
|
+
sys_id,
|
|
452
|
+
table_name,
|
|
453
|
+
str(req_err),
|
|
454
|
+
)
|
|
455
|
+
except Exception as err:
|
|
456
|
+
logger.error(
|
|
457
|
+
"An error occurred while resolving -> %s in table -> '%s': %s",
|
|
458
|
+
sys_id,
|
|
459
|
+
table_name,
|
|
460
|
+
str(err),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
# end method definition
|
|
466
|
+
|
|
467
|
+
def get_summary(self, summary_sys_id: str) -> dict | None:
|
|
468
|
+
"""Get summary object for an article.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
summary_sys_id (str): System ID of the article
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
dict | None: dictionary with the summary
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
return self.get_object(table_name="kb_knowledge_summary", sys_id=summary_sys_id)
|
|
478
|
+
|
|
479
|
+
# end method definition
|
|
480
|
+
|
|
481
|
+
def get_table(
|
|
482
|
+
self,
|
|
483
|
+
table_name: str,
|
|
484
|
+
query: str = "",
|
|
485
|
+
fields: list | None = None,
|
|
486
|
+
limit: int | None = 10,
|
|
487
|
+
offset: int = 0,
|
|
488
|
+
error_string: str = "",
|
|
489
|
+
) -> list | None:
|
|
490
|
+
"""Retrieve a specified ServiceNow table data (row or values).
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
table_name (str): Name of the ServiceNow table
|
|
494
|
+
query (str, optional): Query to filter the table rows (e.g. articles).
|
|
495
|
+
fields (list, optional): Just return the fileds in this list.
|
|
496
|
+
Defaults to None which means to deliver
|
|
497
|
+
all fields.
|
|
498
|
+
limit (int, optional): Number of results to return. None = unlimited.
|
|
499
|
+
offset (int, optional): first item to return (for chunking)
|
|
500
|
+
error_string (str, optional): custom error string
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
list | None: List or articles or None if the request fails.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
request_header = self.request_header()
|
|
507
|
+
|
|
508
|
+
params = {}
|
|
509
|
+
|
|
510
|
+
if query:
|
|
511
|
+
params["sysparm_query"] = query
|
|
512
|
+
if fields:
|
|
513
|
+
params["sysparm_fields"] = ",".join(fields)
|
|
514
|
+
if limit:
|
|
515
|
+
params["sysparm_limit"] = limit
|
|
516
|
+
if offset:
|
|
517
|
+
params["sysparm_offset"] = offset
|
|
518
|
+
|
|
519
|
+
encoded_query = urllib.parse.urlencode(params, doseq=True)
|
|
520
|
+
|
|
521
|
+
request_url = self.config()["tableUrl"] + "/{}?{}".format(
|
|
522
|
+
table_name, encoded_query
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
while True:
|
|
527
|
+
response = self._session.get(
|
|
528
|
+
url=request_url, headers=request_header # , params=params
|
|
529
|
+
)
|
|
530
|
+
data = self.parse_request_response(response)
|
|
531
|
+
|
|
532
|
+
if response.status_code == 200:
|
|
533
|
+
return data.get("result", [])
|
|
534
|
+
elif response.status_code == 202:
|
|
535
|
+
logger.warning(
|
|
536
|
+
"Service Now returned <202 Accepted> -> throtteling, retrying ..."
|
|
537
|
+
)
|
|
538
|
+
time.sleep(1000)
|
|
539
|
+
else:
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
except HTTPError as http_err:
|
|
543
|
+
logger.error("%sHTTP error -> %s!", error_string, str(http_err))
|
|
544
|
+
except RequestException as req_err:
|
|
545
|
+
logger.error("%sRequest error -> %s!", error_string, str(req_err))
|
|
546
|
+
except Exception as err:
|
|
547
|
+
logger.error("%sError -> %s!", error_string, str(err))
|
|
548
|
+
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
# end method definition
|
|
552
|
+
|
|
553
|
+
def get_table_count(
|
|
554
|
+
self,
|
|
555
|
+
table_name: str,
|
|
556
|
+
query: str | None = None,
|
|
557
|
+
) -> int:
|
|
558
|
+
"""Get number of table rows (e.g. Knowledge Base Articles) matching the query
|
|
559
|
+
(or if query = "" it should be the total number)
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
table_name (str): name of the ServiceNow table
|
|
563
|
+
query (str, optional): Query string to filter the results. Defaults to "".
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
int: Number of table rows.
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
request_header = self.request_header()
|
|
570
|
+
|
|
571
|
+
params = {"sysparm_count": "true"}
|
|
572
|
+
|
|
573
|
+
if query:
|
|
574
|
+
params["sysparm_query"] = query
|
|
575
|
+
|
|
576
|
+
encoded_query = urllib.parse.urlencode(params, doseq=True)
|
|
577
|
+
|
|
578
|
+
request_url = self.config()["statsUrl"] + "/{}?{}".format(
|
|
579
|
+
table_name, encoded_query
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
response = self._session.get(
|
|
584
|
+
url=request_url, headers=request_header, timeout=600
|
|
585
|
+
)
|
|
586
|
+
data = self.parse_request_response(response)
|
|
587
|
+
return int(data["result"]["stats"]["count"])
|
|
588
|
+
except HTTPError as http_err:
|
|
589
|
+
logger.error("HTTP error occurred -> %s!", str(http_err))
|
|
590
|
+
except RequestException as req_err:
|
|
591
|
+
logger.error("Request error occurred -> %s!", str(req_err))
|
|
592
|
+
except Exception as err:
|
|
593
|
+
logger.error("An error occurred -> %s!", str(err))
|
|
594
|
+
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
# end method definition
|
|
598
|
+
|
|
599
|
+
def get_categories(self) -> list | None:
|
|
600
|
+
"""Get the configured knowledge base categories in ServiceNow.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
list | None: list of configured knowledge base categories or None in case of an error.
|
|
604
|
+
|
|
605
|
+
Example:
|
|
606
|
+
[
|
|
607
|
+
{
|
|
608
|
+
'sys_mod_count': '2',
|
|
609
|
+
'active': 'true',
|
|
610
|
+
'full_category': 'Patch / Rollup/Set',
|
|
611
|
+
'label': 'Rollup/Set',
|
|
612
|
+
'sys_updated_on': '2022-04-04 16:33:57',
|
|
613
|
+
'sys_domain_path': '/',
|
|
614
|
+
'sys_tags': '',
|
|
615
|
+
'parent_table': 'kb_category',
|
|
616
|
+
'sys_id': '05915bc91b1ac9109b6987b7624bcbed',
|
|
617
|
+
'sys_updated_by': 'vbalachandra@opentext.com',
|
|
618
|
+
'parent_id': {
|
|
619
|
+
'link': 'https://support-qa.opentext.com/api/now/table/kb_category/395093891b1ac9109b6987b7624bcb1b',
|
|
620
|
+
'value': '395093891b1ac9109b6987b7624bcb1b'
|
|
621
|
+
},
|
|
622
|
+
'sys_created_on': '2022-03-16 09:53:56',
|
|
623
|
+
'sys_domain': {
|
|
624
|
+
'link': 'https://support-qa.opentext.com/api/now/table/sys_user_group/global',
|
|
625
|
+
'value': 'global'
|
|
626
|
+
},
|
|
627
|
+
'value': 'rollup_set',
|
|
628
|
+
'sys_created_by': 'tiychowdhury@opentext.com'
|
|
629
|
+
}
|
|
630
|
+
]
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
return self.get_table(
|
|
634
|
+
table_name=SN_TABLE_CATEGORIES,
|
|
635
|
+
error_string="Cannot get Categories; ",
|
|
636
|
+
limit=50,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# end method definition
|
|
640
|
+
|
|
641
|
+
def get_knowledge_bases(self) -> list | None:
|
|
642
|
+
"""Get the configured knowledge bases in ServiceNow.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
list | None: list of configured knowledge bases or None in case of an error.
|
|
646
|
+
|
|
647
|
+
Example:
|
|
648
|
+
[
|
|
649
|
+
{
|
|
650
|
+
'mandatory_fields': '',
|
|
651
|
+
'template': '',
|
|
652
|
+
'enable_socialqa': 'false',
|
|
653
|
+
'icon': '', 'description': '',
|
|
654
|
+
'question_annotation': '',
|
|
655
|
+
'sys_updated_on': '2022-10-05 18:55:55',
|
|
656
|
+
'title': 'Support articles, alerts & useful tools',
|
|
657
|
+
'disable_suggesting': 'false',
|
|
658
|
+
'related_products': '',
|
|
659
|
+
'sys_id': '58819851db61b41068cfd6c4e29619bf',
|
|
660
|
+
'disable_category_editing': 'true',
|
|
661
|
+
'enable_blocks': 'true',
|
|
662
|
+
'sys_updated_by': 'nmohamme@opentext.com',
|
|
663
|
+
'article_validity': '',
|
|
664
|
+
'disable_commenting': 'true',
|
|
665
|
+
'sys_created_on': '2021-07-23 11:37:50',
|
|
666
|
+
'sys_domain': {...},
|
|
667
|
+
'kb_version': '3',
|
|
668
|
+
'sys_created_by': 'marquezj',
|
|
669
|
+
'table': 'kb_knowledge',
|
|
670
|
+
'order': '',
|
|
671
|
+
'owner': {
|
|
672
|
+
'link': 'https://support.opentext.com/api/now/table/sys_user/053429e31b5f0114fea2ec20604bcb95',
|
|
673
|
+
'value': '053429e31b5f0114fea2ec20604bcb95'
|
|
674
|
+
},
|
|
675
|
+
'retire_workflow': {
|
|
676
|
+
'link': 'https://support.opentext.com/api/now/table/wf_workflow/6b3e7ce6dbedb81068cfd6c4e2961936',
|
|
677
|
+
'value': '6b3e7ce6dbedb81068cfd6c4e2961936'
|
|
678
|
+
},
|
|
679
|
+
'languages': 'en,fq,de,ja,es,pb',
|
|
680
|
+
'workflow': {
|
|
681
|
+
'link': 'https://support.opentext.com/api/now/table/wf_workflow/184cb8e2dbedb81068cfd6c4e296199c',
|
|
682
|
+
'value': '184cb8e2dbedb81068cfd6c4e296199c'
|
|
683
|
+
},
|
|
684
|
+
'approval_description': '',
|
|
685
|
+
'disable_mark_as_helpful': 'false',
|
|
686
|
+
'sys_mod_count': '76',
|
|
687
|
+
'active': 'true',
|
|
688
|
+
'sys_domain_path': '/',
|
|
689
|
+
'sys_tags': '',
|
|
690
|
+
'application': {
|
|
691
|
+
'link': 'https://support.opentext.com/api/now/table/sys_scope/global',
|
|
692
|
+
'value': 'global'
|
|
693
|
+
},
|
|
694
|
+
'card_color': '',
|
|
695
|
+
'disable_rating': 'false',
|
|
696
|
+
'create_translation_task': 'false',
|
|
697
|
+
'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec,2a685f4c1be7811461a7a8e22a4bcbfd,6cc3c3d2db21781068cfd6c4e2961962,053429e31b5f0114fea2ec20604bcb95,5454eb441b6b0514fea2ec20604bcbfc,3a17970c1be7811461a7a8e22a4bcb23'
|
|
698
|
+
},
|
|
699
|
+
...
|
|
700
|
+
]
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
return self.get_table(
|
|
704
|
+
table_name=SN_TABLE_KNOWLEDGE_BASES,
|
|
705
|
+
error_string="Cannot get Knowledge Bases; ",
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# end method definition
|
|
709
|
+
|
|
710
|
+
def get_knowledge_base_articles(
|
|
711
|
+
self,
|
|
712
|
+
table_name: str = SN_TABLE_KNOWLEDGE_BASE_ARTICLES,
|
|
713
|
+
query: str = "",
|
|
714
|
+
fields: list | None = None,
|
|
715
|
+
limit: int | None = 10,
|
|
716
|
+
offset: int = 0,
|
|
717
|
+
) -> list | None:
|
|
718
|
+
"""Get selected / filtered Knowledge Base articles
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
query (str, optional): Query to filter the the articles.
|
|
722
|
+
fields (list, optional): Just return the fileds in this list.
|
|
723
|
+
Defaults to None which means to deliver
|
|
724
|
+
all fields.
|
|
725
|
+
limit (int, optional): Number of results to return. None = unlimited.
|
|
726
|
+
offset (int, optional): first item to return (for chunking)
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
list | None: List or articles or None if the request fails.
|
|
730
|
+
|
|
731
|
+
Example:
|
|
732
|
+
[
|
|
733
|
+
{
|
|
734
|
+
'parent': '',
|
|
735
|
+
'wiki': None,
|
|
736
|
+
'rating': '',
|
|
737
|
+
'language': 'en',
|
|
738
|
+
'source': '',
|
|
739
|
+
'sys_updated_on': '2024-02-28 21:37:47',
|
|
740
|
+
'number': 'KB0530086',
|
|
741
|
+
'u_sub_product_line': 'cc1c280387655d506d9a2f8f8bbb35e0',
|
|
742
|
+
'sys_updated_by': 'scotts@opentext.com',
|
|
743
|
+
'sys_created_on': '2024-02-28 21:37:16',
|
|
744
|
+
'sys_domain': {
|
|
745
|
+
'link': 'https://support.opentext.com/api/now/table/sys_user_group/global',
|
|
746
|
+
'value': 'global'
|
|
747
|
+
},
|
|
748
|
+
'workflow_state': 'published',
|
|
749
|
+
'text': '',
|
|
750
|
+
'sys_created_by': 'scotts@opentext.com',
|
|
751
|
+
'scheduled_publish_date': '',
|
|
752
|
+
'image': '',
|
|
753
|
+
'author': {
|
|
754
|
+
'link': 'https://support.opentext.com/api/now/table/sys_user/ffd35065875499109fdd2f8f8bbb353f',
|
|
755
|
+
'value': 'ffd35065875499109fdd2f8f8bbb353f'
|
|
756
|
+
},
|
|
757
|
+
'u_related_products_text_search': '<br /><li>LearnFlex APP0578<br /></li>',
|
|
758
|
+
'can_read_user_criteria': 'de3a815b1b0601109b6987b7624bcba6',
|
|
759
|
+
'active': 'true',
|
|
760
|
+
'cannot_read_user_criteria': '',
|
|
761
|
+
'published': '2024-02-28',
|
|
762
|
+
'helpful_count': '0',
|
|
763
|
+
'sys_domain_path': '/',
|
|
764
|
+
'version': {
|
|
765
|
+
'link': 'https://support.opentext.com/api/now/table/kb_version/7cd172cf1b6cca10d7604223cd4bcb99',
|
|
766
|
+
'value': '7cd172cf1b6cca10d7604223cd4bcb99'
|
|
767
|
+
},
|
|
768
|
+
'meta_description': 'In LearnFlex, what types of messages are in message management?',
|
|
769
|
+
'kb_knowledge_base': {
|
|
770
|
+
'link': 'https://support.opentext.com/api/now/table/kb_knowledge_base/58819851db61b41068cfd6c4e29619bf',
|
|
771
|
+
'value': '58819851db61b41068cfd6c4e29619bf'
|
|
772
|
+
},
|
|
773
|
+
'meta': 'LearnFlex, 384, Message_Management, Message',
|
|
774
|
+
'u_platform_choice': '',
|
|
775
|
+
'topic': 'General',
|
|
776
|
+
'display_number': 'KB0530086 v3.0',
|
|
777
|
+
'u_product_line': '1f401ecc1bf6891061a7a8e22a4bcb7d',
|
|
778
|
+
'base_version': {
|
|
779
|
+
'link': 'https://support.opentext.com/api/now/table/kb_knowledge/740fbd4547651910ab0a9ed7536d4350',
|
|
780
|
+
'value': '740fbd4547651910ab0a9ed7536d4350'
|
|
781
|
+
},
|
|
782
|
+
'short_description': 'LearnFlex - What Types of Messages are in Message Management?',
|
|
783
|
+
'u_available_translations': 'English',
|
|
784
|
+
'u_limited_release': 'No',
|
|
785
|
+
'u_internal_review': '',
|
|
786
|
+
'roles': '',
|
|
787
|
+
'direct': 'false',
|
|
788
|
+
'description': '',
|
|
789
|
+
'disable_suggesting': 'false',
|
|
790
|
+
'related_products': '52609e001b3a891061a7a8e22a4bcb96',
|
|
791
|
+
'sys_class_name': 'u_kb_template_technical_article_public',
|
|
792
|
+
'article_id': '740fbd4547651910ab0a9ed7536d4350',
|
|
793
|
+
'sys_id': '91b13e8f1b6cca10d7604223cd4bcbc1',
|
|
794
|
+
'use_count': '0',
|
|
795
|
+
'flagged': 'false',
|
|
796
|
+
'disable_commenting': 'true',
|
|
797
|
+
'valid_to': '',
|
|
798
|
+
'retired': '',
|
|
799
|
+
'u_kc_object_id': '',
|
|
800
|
+
'u_download_url': '',
|
|
801
|
+
'display_attachments': 'false',
|
|
802
|
+
'latest': 'true',
|
|
803
|
+
'summary': {
|
|
804
|
+
'link': 'https://support.opentext.com/api/now/table/kb_knowledge_summary/410fbd4547651910ab0a9ed7536d4356',
|
|
805
|
+
'value': '410fbd4547651910ab0a9ed7536d4356'
|
|
806
|
+
},
|
|
807
|
+
'sys_view_count': '2',
|
|
808
|
+
'revised_by': {
|
|
809
|
+
'link': 'https://support.opentext.com/api/now/table/sys_user/6fea35401ba3811461a7a8e22a4bcb59',
|
|
810
|
+
'value': '6fea35401ba3811461a7a8e22a4bcb59'
|
|
811
|
+
},
|
|
812
|
+
'article_type': 'text',
|
|
813
|
+
'u_internal_class': '',
|
|
814
|
+
'u_kc_parent_id': '',
|
|
815
|
+
'confidence': 'validated',
|
|
816
|
+
'sys_mod_count': '4',
|
|
817
|
+
'sys_tags': '',
|
|
818
|
+
'replacement_article': '',
|
|
819
|
+
'taxonomy_topic': '',
|
|
820
|
+
'u_application': '52609e001b3a891061a7a8e22a4bcb96',
|
|
821
|
+
'view_as_allowed': 'true',
|
|
822
|
+
'ownership_group': {
|
|
823
|
+
'link': 'https://support.opentext.com/api/now/table/sys_user_group/9a1f66a0473d6d10b6a6778bd36d4375',
|
|
824
|
+
'value': '9a1f66a0473d6d10b6a6778bd36d4375'
|
|
825
|
+
},
|
|
826
|
+
'category': '',
|
|
827
|
+
'kb_category': {
|
|
828
|
+
'link': 'https://support.opentext.com/api/now/table/kb_category/d0144f5edb21781068cfd6c4e2961992',
|
|
829
|
+
'value': 'd0144f5edb21781068cfd6c4e2961992'
|
|
830
|
+
},
|
|
831
|
+
'governance': 'experience'
|
|
832
|
+
},
|
|
833
|
+
...
|
|
834
|
+
]
|
|
835
|
+
"""
|
|
836
|
+
|
|
837
|
+
return self.get_table(
|
|
838
|
+
table_name=table_name, # derived from table kb_knowledge
|
|
839
|
+
query=query,
|
|
840
|
+
fields=fields,
|
|
841
|
+
limit=limit,
|
|
842
|
+
offset=offset,
|
|
843
|
+
error_string="Cannot get knowledge base articles; ",
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# end method definition
|
|
847
|
+
|
|
848
|
+
def make_file_names_unique(self, file_list: list):
|
|
849
|
+
"""Make file names unique if required. The mutable
|
|
850
|
+
list is changed "in-place".
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
file_list (list): list of attachments as dictionaries
|
|
854
|
+
with "sys_id" and "file_name" keys.
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
# Dictionary to keep track of how many times each file name has been encountered
|
|
858
|
+
name_count = {}
|
|
859
|
+
|
|
860
|
+
# Iterate through the list of dictionaries
|
|
861
|
+
for file_info in file_list:
|
|
862
|
+
original_name = file_info["file_name"]
|
|
863
|
+
name, ext = os.path.splitext(original_name)
|
|
864
|
+
|
|
865
|
+
# Initialize count if this is the first time the name is encountered
|
|
866
|
+
if original_name not in name_count:
|
|
867
|
+
name_count[original_name] = 0
|
|
868
|
+
|
|
869
|
+
# Generate a unique file name if the original name has been seen before
|
|
870
|
+
if name_count[original_name] > 0:
|
|
871
|
+
new_name = f"{name} ({name_count[original_name]:02}){ext}"
|
|
872
|
+
# Check if this new name already exists in the list to avoid collisions.
|
|
873
|
+
# If it does, increment the suffix number until a unique name is found.
|
|
874
|
+
while any(f["file_name"] == new_name for f in file_list):
|
|
875
|
+
name_count[original_name] += 1
|
|
876
|
+
new_name = f"{name} ({name_count[original_name]:02}){ext}"
|
|
877
|
+
file_info["file_name"] = new_name
|
|
878
|
+
|
|
879
|
+
# Increment the count for this file name
|
|
880
|
+
name_count[original_name] += 1
|
|
881
|
+
|
|
882
|
+
# end method definition
|
|
883
|
+
|
|
884
|
+
def get_article_attachments(self, article: dict) -> list | None:
|
|
885
|
+
"""Get a list of attachments for an article
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
article (dict): Article information
|
|
889
|
+
|
|
890
|
+
Returns:
|
|
891
|
+
list | None: list of attachments
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
article_sys_id = article["sys_id"]
|
|
895
|
+
article_number = article["number"]
|
|
896
|
+
|
|
897
|
+
request_header = self.request_header()
|
|
898
|
+
request_url = self.config()["attachmentsUrl"]
|
|
899
|
+
|
|
900
|
+
params = {
|
|
901
|
+
"sysparm_query": "table_sys_id={}".format(article_sys_id),
|
|
902
|
+
"sysparm_fields": "sys_id,file_name",
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
response = self._session.get(
|
|
907
|
+
url=request_url, headers=request_header, params=params
|
|
908
|
+
)
|
|
909
|
+
data = self.parse_request_response(response)
|
|
910
|
+
attachments = data.get("result", [])
|
|
911
|
+
if not attachments:
|
|
912
|
+
logger.debug(
|
|
913
|
+
"Knowledge base article -> %s does not have attachments!",
|
|
914
|
+
article_number,
|
|
915
|
+
)
|
|
916
|
+
return []
|
|
917
|
+
else:
|
|
918
|
+
logger.info(
|
|
919
|
+
"Knowledge base article -> %s has %s attachments.",
|
|
920
|
+
article_number,
|
|
921
|
+
len(attachments),
|
|
922
|
+
)
|
|
923
|
+
return attachments
|
|
924
|
+
|
|
925
|
+
except HTTPError as http_err:
|
|
926
|
+
logger.error("HTTP error occurred -> %s!", str(http_err))
|
|
927
|
+
except RequestException as req_err:
|
|
928
|
+
logger.error("Request error occurred -> %s!", str(req_err))
|
|
929
|
+
except Exception as err:
|
|
930
|
+
logger.error("An error occurred -> %s!", str(err))
|
|
931
|
+
|
|
932
|
+
return None
|
|
933
|
+
|
|
934
|
+
# end method definition
|
|
935
|
+
|
|
936
|
+
def download_attachments(
|
|
937
|
+
self,
|
|
938
|
+
article: dict,
|
|
939
|
+
skip_existing: bool = True,
|
|
940
|
+
) -> bool:
|
|
941
|
+
"""Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
article (dict): dictionary holding the Service Now article data
|
|
945
|
+
skip_existing (bool, optional): skip download if file has been downloaded before
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
bool: True = success, False = failure
|
|
949
|
+
"""
|
|
950
|
+
|
|
951
|
+
article_number = article["number"]
|
|
952
|
+
|
|
953
|
+
attachments = self.get_article_attachments(article)
|
|
954
|
+
|
|
955
|
+
if not attachments:
|
|
956
|
+
logger.debug(
|
|
957
|
+
"Knowledge base article -> %s does not have attachments to download!",
|
|
958
|
+
article_number,
|
|
959
|
+
)
|
|
960
|
+
article["has_attachments"] = False
|
|
961
|
+
return False
|
|
962
|
+
else:
|
|
963
|
+
logger.info(
|
|
964
|
+
"Knowledge base article -> %s has %s attachments to download...",
|
|
965
|
+
article_number,
|
|
966
|
+
len(attachments),
|
|
967
|
+
)
|
|
968
|
+
article["has_attachments"] = True
|
|
969
|
+
|
|
970
|
+
# Service Now can have multiple files with the same name - we need to
|
|
971
|
+
# resolve this for Extended ECM:
|
|
972
|
+
self.make_file_names_unique(attachments)
|
|
973
|
+
|
|
974
|
+
base_dir = os.path.join(self._download_dir, article_number)
|
|
975
|
+
|
|
976
|
+
# save download dir for later use in bulkDocument processing...
|
|
977
|
+
article["download_dir"] = base_dir
|
|
978
|
+
|
|
979
|
+
article["download_files"] = []
|
|
980
|
+
article["download_files_ids"] = []
|
|
981
|
+
|
|
982
|
+
if not os.path.exists(base_dir):
|
|
983
|
+
os.makedirs(base_dir)
|
|
984
|
+
|
|
985
|
+
for attachment in attachments:
|
|
986
|
+
file_path = os.path.join(base_dir, attachment["file_name"])
|
|
987
|
+
|
|
988
|
+
if os.path.exists(file_path) and skip_existing:
|
|
989
|
+
logger.info(
|
|
990
|
+
"File -> %s has been downloaded before. Skipping download...",
|
|
991
|
+
file_path,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# we need to add file_name and sys_id in the list of files and for later use in bulkDocument processing...
|
|
995
|
+
article["download_files"].append(attachment["file_name"])
|
|
996
|
+
article["download_files_ids"].append(attachment["sys_id"])
|
|
997
|
+
continue
|
|
998
|
+
attachment_download_url = (
|
|
999
|
+
self.config()["attachmentDownloadUrl"]
|
|
1000
|
+
+ "/"
|
|
1001
|
+
+ attachment["sys_id"]
|
|
1002
|
+
+ "/file"
|
|
1003
|
+
)
|
|
1004
|
+
try:
|
|
1005
|
+
logger.info(
|
|
1006
|
+
"Downloading attachment file -> '%s' for article -> %s from ServiceNow...",
|
|
1007
|
+
file_path,
|
|
1008
|
+
article_number,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
attachment_response = self._session.get(
|
|
1012
|
+
attachment_download_url, stream=True
|
|
1013
|
+
)
|
|
1014
|
+
attachment_response.raise_for_status()
|
|
1015
|
+
|
|
1016
|
+
with open(file_path, "wb") as file:
|
|
1017
|
+
for chunk in attachment_response.iter_content(chunk_size=8192):
|
|
1018
|
+
file.write(chunk)
|
|
1019
|
+
|
|
1020
|
+
# we build a list of filenames and ids.
|
|
1021
|
+
# the ids we want to use as nicknames later on
|
|
1022
|
+
article["download_files"].append(attachment["file_name"])
|
|
1023
|
+
article["download_files_ids"].append(attachment["sys_id"])
|
|
1024
|
+
|
|
1025
|
+
except HTTPError as e:
|
|
1026
|
+
logger.error(
|
|
1027
|
+
"Failed to download -> '%s' using url -> %s; error -> %s",
|
|
1028
|
+
attachment["file_name"],
|
|
1029
|
+
attachment_download_url,
|
|
1030
|
+
str(e),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
return True
|
|
1034
|
+
|
|
1035
|
+
# end method definition
|
|
1036
|
+
|
|
1037
|
+
def load_articles(self, table_name: str, query: str | None) -> bool:
|
|
1038
|
+
"""Main method to load ServiceNow articles in a Data Frame and
|
|
1039
|
+
download the attchments.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
query (str): Filter criteria for the articles.
|
|
1043
|
+
|
|
1044
|
+
Returns:
|
|
1045
|
+
bool: True = Success, False = Failure
|
|
1046
|
+
"""
|
|
1047
|
+
|
|
1048
|
+
total_count = self.get_table_count(table_name=table_name, query=query)
|
|
1049
|
+
|
|
1050
|
+
logger.info(
|
|
1051
|
+
"Total number of Knowledge Base Articles (KBA) -> %s", str(total_count)
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
if total_count == 0:
|
|
1055
|
+
logger.info(
|
|
1056
|
+
"Query does not return any value from ServiceNow table -> '%s'. Finishing.",
|
|
1057
|
+
table_name,
|
|
1058
|
+
)
|
|
1059
|
+
return True
|
|
1060
|
+
|
|
1061
|
+
number = self._thread_number
|
|
1062
|
+
|
|
1063
|
+
if total_count >= number:
|
|
1064
|
+
partition_size = total_count // number
|
|
1065
|
+
remainder = total_count % number
|
|
1066
|
+
else:
|
|
1067
|
+
partition_size = total_count
|
|
1068
|
+
remainder = 0
|
|
1069
|
+
number = 1
|
|
1070
|
+
|
|
1071
|
+
logger.info(
|
|
1072
|
+
"Processing -> %s Knowledge Base Articles (KBA), table name -> '%s', thread number -> %s, partition size -> %s",
|
|
1073
|
+
str(total_count),
|
|
1074
|
+
table_name,
|
|
1075
|
+
number,
|
|
1076
|
+
partition_size,
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
threads = []
|
|
1080
|
+
|
|
1081
|
+
current_offset = 0
|
|
1082
|
+
for i in range(number):
|
|
1083
|
+
current_partition_size = partition_size + (1 if i < remainder else 0)
|
|
1084
|
+
thread = threading.Thread(
|
|
1085
|
+
name=f"load_articles_{i+1:02}",
|
|
1086
|
+
target=self.thread_wrapper,
|
|
1087
|
+
args=(
|
|
1088
|
+
self.load_articles_worker,
|
|
1089
|
+
table_name,
|
|
1090
|
+
query,
|
|
1091
|
+
current_partition_size,
|
|
1092
|
+
current_offset,
|
|
1093
|
+
),
|
|
1094
|
+
)
|
|
1095
|
+
thread.start()
|
|
1096
|
+
threads.append(thread)
|
|
1097
|
+
current_offset += current_partition_size
|
|
1098
|
+
|
|
1099
|
+
for thread in threads:
|
|
1100
|
+
thread.join()
|
|
1101
|
+
|
|
1102
|
+
return True
|
|
1103
|
+
|
|
1104
|
+
# end method definition
|
|
1105
|
+
|
|
1106
|
+
def load_articles_worker(
|
|
1107
|
+
self, table_name: str, query: str, partition_size: int, partition_offset: int
|
|
1108
|
+
) -> None:
|
|
1109
|
+
"""Worker Method for multi-threading.
|
|
1110
|
+
|
|
1111
|
+
Args:
|
|
1112
|
+
query (str): Query to select the relevant KBA.
|
|
1113
|
+
partition_size (int): Total size of the partition assigned to this thread.
|
|
1114
|
+
partition_offset (int): Starting offset for the KBAs this thread is processing.
|
|
1115
|
+
"""
|
|
1116
|
+
|
|
1117
|
+
logger.info(
|
|
1118
|
+
"Start processing KBAs in range from -> %s to -> %s from table -> '%s'...",
|
|
1119
|
+
partition_offset,
|
|
1120
|
+
partition_offset + partition_size,
|
|
1121
|
+
table_name,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
# We cannot retrieve all KBAs in one go if the partition size is too big (> 100)
|
|
1125
|
+
# So we define "limit" as the maximum number of KBAs we want to retrieve for one REST call.
|
|
1126
|
+
# This should be a reasonable number to avoid timeouts. We also need to make sure
|
|
1127
|
+
# the limit is not bigger than the the partition size:
|
|
1128
|
+
limit = 100 if partition_size > 100 else partition_size
|
|
1129
|
+
|
|
1130
|
+
for offset in range(partition_offset, partition_offset + partition_size, limit):
|
|
1131
|
+
articles = self.get_table(
|
|
1132
|
+
table_name=table_name, query=query, limit=limit, offset=offset
|
|
1133
|
+
)
|
|
1134
|
+
logger.info(
|
|
1135
|
+
"Retrieved a list of %s KBAs starting at offset -> %s to process.",
|
|
1136
|
+
str(len(articles)),
|
|
1137
|
+
offset,
|
|
1138
|
+
)
|
|
1139
|
+
for article in articles:
|
|
1140
|
+
logger.info("Processing KBA -> %s...", article["number"])
|
|
1141
|
+
article["source_table"] = table_name
|
|
1142
|
+
self.load_article(article)
|
|
1143
|
+
|
|
1144
|
+
logger.info(
|
|
1145
|
+
"Finished processing KBAs in range from -> %s to -> %s from table -> '%s'.",
|
|
1146
|
+
partition_offset,
|
|
1147
|
+
partition_offset + partition_size,
|
|
1148
|
+
table_name,
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
# end method definition
|
|
1152
|
+
|
|
1153
|
+
def load_article(self, article: dict, skip_existing_downloads: bool = True):
|
|
1154
|
+
"""Process a single KBA: download attachments (if any), add additional
|
|
1155
|
+
keys / values to the article from other ServiceNow tables,
|
|
1156
|
+
and finally add the KBA to the Data Frame.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
article (dict): Dictionary inclusing all fields of
|
|
1160
|
+
a single KBA. This is a mutable variable
|
|
1161
|
+
that gets modified by this method!
|
|
1162
|
+
|
|
1163
|
+
Side effect:
|
|
1164
|
+
The article dict is modified with by adding additional key / value
|
|
1165
|
+
pairs (these can be used in the payload files!):
|
|
1166
|
+
|
|
1167
|
+
* kb_category_name - the readable name of the ServiceNow category
|
|
1168
|
+
* kb_knowledge_base_name - the readable name of the ServiceNow KnowledgeBase
|
|
1169
|
+
* related_product_names - this list includes the related product names for the article
|
|
1170
|
+
* u_product_line_names - this list includes the related product line names for the article
|
|
1171
|
+
* u_sub_product_line_names - this list includes the related sub product line names for the article
|
|
1172
|
+
* u_application_names - this list includes the related application names for the article
|
|
1173
|
+
* u_application_versions - this list includes the related application versions for the article
|
|
1174
|
+
* u_application_version_sets - this table includes lines for each application + version. Sub items:
|
|
1175
|
+
- u_product_model - name of the application
|
|
1176
|
+
- u_version_name - name of the version - e.g. 24.4
|
|
1177
|
+
|
|
1178
|
+
"""
|
|
1179
|
+
|
|
1180
|
+
_ = self.download_attachments(
|
|
1181
|
+
article=article, skip_existing=skip_existing_downloads
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
#
|
|
1185
|
+
# Add additional columns from related ServiceNow tables:
|
|
1186
|
+
#
|
|
1187
|
+
|
|
1188
|
+
if "kb_category" in article and article["kb_category"]:
|
|
1189
|
+
category_key = article.get("kb_category")["value"]
|
|
1190
|
+
category_table_name = SN_TABLE_CATEGORIES
|
|
1191
|
+
category = self.get_object(
|
|
1192
|
+
table_name=category_table_name, sys_id=category_key
|
|
1193
|
+
)
|
|
1194
|
+
if category:
|
|
1195
|
+
article["kb_category_name"] = self.get_result_value(
|
|
1196
|
+
response=category, key="full_category"
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
logger.warning(
|
|
1200
|
+
"Article -> %s has no category value!", article["number"]
|
|
1201
|
+
)
|
|
1202
|
+
article["kb_category_name"] = ""
|
|
1203
|
+
else:
|
|
1204
|
+
logger.warning(
|
|
1205
|
+
"Article -> %s has no value for category!", article["number"]
|
|
1206
|
+
)
|
|
1207
|
+
article["kb_category_name"] = ""
|
|
1208
|
+
|
|
1209
|
+
knowledge_base_key = article.get("kb_knowledge_base")["value"]
|
|
1210
|
+
knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
|
|
1211
|
+
knowledge_base = self.get_object(
|
|
1212
|
+
table_name=knowledge_base_table_name, sys_id=knowledge_base_key
|
|
1213
|
+
)
|
|
1214
|
+
if knowledge_base:
|
|
1215
|
+
article["kb_knowledge_base_name"] = self.get_result_value(
|
|
1216
|
+
response=knowledge_base, key="title"
|
|
1217
|
+
)
|
|
1218
|
+
else:
|
|
1219
|
+
logger.warning(
|
|
1220
|
+
"Article -> %s has no value for Knowledge Base!",
|
|
1221
|
+
article["number"],
|
|
1222
|
+
)
|
|
1223
|
+
article["kb_knowledge_base_name"] = ""
|
|
1224
|
+
|
|
1225
|
+
related_product_names = []
|
|
1226
|
+
if article.get("related_products", None):
|
|
1227
|
+
related_product_keys = article.get("related_products").split(",")
|
|
1228
|
+
for related_product_key in related_product_keys:
|
|
1229
|
+
related_product = self.get_object(
|
|
1230
|
+
table_name=SN_TABLE_RELATED_PRODUCTS, sys_id=related_product_key
|
|
1231
|
+
)
|
|
1232
|
+
if related_product:
|
|
1233
|
+
related_product_name = self.get_result_value(
|
|
1234
|
+
response=related_product, key="name"
|
|
1235
|
+
)
|
|
1236
|
+
logger.debug(
|
|
1237
|
+
"Found related Product -> '%s' (%s)",
|
|
1238
|
+
related_product_name,
|
|
1239
|
+
related_product_key,
|
|
1240
|
+
)
|
|
1241
|
+
related_product_names.append(related_product_name)
|
|
1242
|
+
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1243
|
+
if len(related_product_names) == 49:
|
|
1244
|
+
logger.info(
|
|
1245
|
+
"Reached maximum of 50 multi-value items for related Products of article -> %s",
|
|
1246
|
+
article["number"],
|
|
1247
|
+
)
|
|
1248
|
+
break
|
|
1249
|
+
else:
|
|
1250
|
+
logger.warning(
|
|
1251
|
+
"Article -> %s: Cannot lookup related Product name in table -> '%s' with ID -> %s",
|
|
1252
|
+
article["number"],
|
|
1253
|
+
SN_TABLE_RELATED_PRODUCTS,
|
|
1254
|
+
related_product_key,
|
|
1255
|
+
)
|
|
1256
|
+
else:
|
|
1257
|
+
logger.warning(
|
|
1258
|
+
"Article -> %s has no value related Products!",
|
|
1259
|
+
article["number"],
|
|
1260
|
+
)
|
|
1261
|
+
article["related_product_names"] = related_product_names
|
|
1262
|
+
|
|
1263
|
+
product_line_names = []
|
|
1264
|
+
if article.get("u_product_line", None):
|
|
1265
|
+
product_line_keys = article.get("u_product_line").split(",")
|
|
1266
|
+
product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1267
|
+
for product_line_key in product_line_keys:
|
|
1268
|
+
product_line = self.get_object(
|
|
1269
|
+
table_name=product_line_table, sys_id=product_line_key
|
|
1270
|
+
)
|
|
1271
|
+
if product_line:
|
|
1272
|
+
product_line_name = self.get_result_value(
|
|
1273
|
+
response=product_line, key="name"
|
|
1274
|
+
)
|
|
1275
|
+
logger.debug(
|
|
1276
|
+
"Found related Product Line -> '%s' (%s)",
|
|
1277
|
+
product_line_name,
|
|
1278
|
+
product_line_key,
|
|
1279
|
+
)
|
|
1280
|
+
product_line_names.append(product_line_name)
|
|
1281
|
+
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1282
|
+
if len(product_line_names) == 49:
|
|
1283
|
+
logger.info(
|
|
1284
|
+
"Reached maximum of 50 multi-value items for related Product Lines of article -> %s",
|
|
1285
|
+
article["number"],
|
|
1286
|
+
)
|
|
1287
|
+
break
|
|
1288
|
+
else:
|
|
1289
|
+
logger.warning(
|
|
1290
|
+
"Article -> %s: Cannot lookup related Product Line name in table -> '%s' with ID -> %s",
|
|
1291
|
+
article["number"],
|
|
1292
|
+
product_line_table,
|
|
1293
|
+
product_line_key,
|
|
1294
|
+
)
|
|
1295
|
+
else:
|
|
1296
|
+
logger.warning(
|
|
1297
|
+
"Article -> %s has no value for related Product Lines!",
|
|
1298
|
+
article["number"],
|
|
1299
|
+
)
|
|
1300
|
+
article["u_product_line_names"] = product_line_names
|
|
1301
|
+
|
|
1302
|
+
sub_product_line_names = []
|
|
1303
|
+
if article.get("u_sub_product_line", None):
|
|
1304
|
+
sub_product_line_keys = article.get("u_sub_product_line").split(",")
|
|
1305
|
+
sub_product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1306
|
+
for sub_product_line_key in sub_product_line_keys:
|
|
1307
|
+
sub_product_line = self.get_object(
|
|
1308
|
+
table_name=sub_product_line_table, sys_id=sub_product_line_key
|
|
1309
|
+
)
|
|
1310
|
+
if sub_product_line:
|
|
1311
|
+
sub_product_line_name = self.get_result_value(
|
|
1312
|
+
response=sub_product_line, key="name"
|
|
1313
|
+
)
|
|
1314
|
+
logger.debug(
|
|
1315
|
+
"Found related Sub Product Line -> '%s' (%s)",
|
|
1316
|
+
sub_product_line_name,
|
|
1317
|
+
sub_product_line_key,
|
|
1318
|
+
)
|
|
1319
|
+
sub_product_line_names.append(sub_product_line_name)
|
|
1320
|
+
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1321
|
+
if len(sub_product_line_names) == 49:
|
|
1322
|
+
logger.info(
|
|
1323
|
+
"Reached maximum of 50 multi-value items for related Sub Product Lines of article -> %s",
|
|
1324
|
+
article["number"],
|
|
1325
|
+
)
|
|
1326
|
+
break
|
|
1327
|
+
else:
|
|
1328
|
+
logger.warning(
|
|
1329
|
+
"Article -> %s: Cannot lookup related Sub Product Line name in table -> '%s' with ID -> %s",
|
|
1330
|
+
article["number"],
|
|
1331
|
+
sub_product_line_table,
|
|
1332
|
+
sub_product_line_key,
|
|
1333
|
+
)
|
|
1334
|
+
else:
|
|
1335
|
+
logger.warning(
|
|
1336
|
+
"Article -> %s has no value for related Sub Product Lines!",
|
|
1337
|
+
article["number"],
|
|
1338
|
+
)
|
|
1339
|
+
article["u_sub_product_line_names"] = sub_product_line_names
|
|
1340
|
+
|
|
1341
|
+
application_names = []
|
|
1342
|
+
if article.get("u_application", None):
|
|
1343
|
+
application_keys = article.get("u_application").split(",")
|
|
1344
|
+
application_table_name = SN_TABLE_PRODUCT_LINES
|
|
1345
|
+
for application_key in application_keys:
|
|
1346
|
+
application = self.get_object(
|
|
1347
|
+
table_name=application_table_name, sys_id=application_key
|
|
1348
|
+
)
|
|
1349
|
+
if application:
|
|
1350
|
+
application_name = self.get_result_value(
|
|
1351
|
+
response=application, key="name"
|
|
1352
|
+
)
|
|
1353
|
+
logger.debug(
|
|
1354
|
+
"Found related Application -> '%s' (%s)",
|
|
1355
|
+
application_name,
|
|
1356
|
+
application_key,
|
|
1357
|
+
)
|
|
1358
|
+
application_names.append(application_name)
|
|
1359
|
+
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1360
|
+
if len(application_names) == 49:
|
|
1361
|
+
logger.info(
|
|
1362
|
+
"Reached maximum of 50 multi-value items for related Applications of article -> %s",
|
|
1363
|
+
article["number"],
|
|
1364
|
+
)
|
|
1365
|
+
break
|
|
1366
|
+
else:
|
|
1367
|
+
logger.warning(
|
|
1368
|
+
"Article -> %s: Cannot lookup related Application name in table -> '%s' with ID -> %s",
|
|
1369
|
+
article["number"],
|
|
1370
|
+
application_table_name,
|
|
1371
|
+
application_key,
|
|
1372
|
+
)
|
|
1373
|
+
else:
|
|
1374
|
+
logger.warning(
|
|
1375
|
+
"Article -> %s has no value for related Applications!",
|
|
1376
|
+
article["number"],
|
|
1377
|
+
)
|
|
1378
|
+
article["u_application_names"] = application_names
|
|
1379
|
+
|
|
1380
|
+
application_versions = []
|
|
1381
|
+
application_version_sets = []
|
|
1382
|
+
if article.get("u_application_version", None):
|
|
1383
|
+
application_version_keys = article.get("u_application_version").split(",")
|
|
1384
|
+
for application_version_key in application_version_keys:
|
|
1385
|
+
# Get the version object from ServiceNow. It includes both,
|
|
1386
|
+
# the application version number and the application name:
|
|
1387
|
+
application_version = self.get_object(
|
|
1388
|
+
table_name=SN_TABLE_PRODUCT_VERSIONS,
|
|
1389
|
+
sys_id=application_version_key,
|
|
1390
|
+
)
|
|
1391
|
+
if application_version:
|
|
1392
|
+
application_version_name = self.get_result_value(
|
|
1393
|
+
response=application_version, key="u_version_name"
|
|
1394
|
+
)
|
|
1395
|
+
logger.debug(
|
|
1396
|
+
"Found related Application Version -> '%s' (%s)",
|
|
1397
|
+
SN_TABLE_PRODUCT_LINES,
|
|
1398
|
+
application_version_key,
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
application_versions.append(application_version_name)
|
|
1402
|
+
|
|
1403
|
+
# Lookup application name of version and fill the set
|
|
1404
|
+
|
|
1405
|
+
application_key = self.get_result_value(
|
|
1406
|
+
response=application_version, key="u_product_model"
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
if application_key:
|
|
1410
|
+
# u_applicatio_model has a substructure like this:
|
|
1411
|
+
# {
|
|
1412
|
+
# 'link': 'https://support.opentext.com/api/now/table/u_ot_product_model/9b2dcea747f6d910ab0a9ed7536d4364',
|
|
1413
|
+
# 'value': '9b2dcea747f6d910ab0a9ed7536d4364'
|
|
1414
|
+
# }
|
|
1415
|
+
# We want the value:
|
|
1416
|
+
application_key = application_key.get("value")
|
|
1417
|
+
|
|
1418
|
+
if application_key:
|
|
1419
|
+
application = self.get_object(
|
|
1420
|
+
table_name=SN_TABLE_PRODUCT_LINES,
|
|
1421
|
+
sys_id=application_key,
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
application_name = self.get_result_value(
|
|
1425
|
+
response=application, key="name"
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
if application_name:
|
|
1429
|
+
application_version_sets.append(
|
|
1430
|
+
{
|
|
1431
|
+
# "Application": application_name,
|
|
1432
|
+
# "Version": application_version_name,
|
|
1433
|
+
"u_product_model": application_name,
|
|
1434
|
+
"u_version_name": application_version_name,
|
|
1435
|
+
}
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1439
|
+
if len(application_versions) == 49:
|
|
1440
|
+
logger.info(
|
|
1441
|
+
"Reached maximum of 50 multi-value items for related Application Version of article -> %s",
|
|
1442
|
+
article["number"],
|
|
1443
|
+
)
|
|
1444
|
+
break
|
|
1445
|
+
else:
|
|
1446
|
+
logger.warning(
|
|
1447
|
+
"Article -> %s: Cannot lookup related Application Version in table -> '%s' with ID -> %s",
|
|
1448
|
+
article["number"],
|
|
1449
|
+
SN_TABLE_PRODUCT_VERSIONS,
|
|
1450
|
+
application_version_key,
|
|
1451
|
+
)
|
|
1452
|
+
else:
|
|
1453
|
+
logger.warning(
|
|
1454
|
+
"Article -> %s has no value for related Application Version!",
|
|
1455
|
+
article["number"],
|
|
1456
|
+
)
|
|
1457
|
+
# Convert to list and set to remove duplicates:
|
|
1458
|
+
article["u_application_versions"] = list(set(application_versions))
|
|
1459
|
+
|
|
1460
|
+
# This set maps the applications and the versions (table-like structure)
|
|
1461
|
+
article["u_application_version_sets"] = application_version_sets
|
|
1462
|
+
|
|
1463
|
+
# Now we add the article to the Pandas Data Frame in the Data class:
|
|
1464
|
+
with self._data.lock():
|
|
1465
|
+
self._data.append(article)
|
|
1466
|
+
|
|
1467
|
+
# end method definition
|