pyxecm 1.6__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 -4
- pyxecm/avts.py +673 -246
- pyxecm/coreshare.py +686 -467
- 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 +1007 -1130
- pyxecm/customizer/exceptions.py +35 -0
- pyxecm/customizer/guidewire.py +322 -0
- pyxecm/customizer/k8s.py +713 -378
- pyxecm/customizer/log.py +107 -0
- pyxecm/customizer/m365.py +2867 -909
- pyxecm/customizer/nhc.py +1169 -0
- pyxecm/customizer/openapi.py +258 -0
- pyxecm/customizer/payload.py +16817 -7467
- pyxecm/customizer/pht.py +699 -285
- pyxecm/customizer/salesforce.py +516 -342
- pyxecm/customizer/sap.py +58 -41
- pyxecm/customizer/servicenow.py +593 -371
- 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 +83 -43
- pyxecm/helper/data.py +2406 -870
- pyxecm/helper/logadapter.py +27 -0
- pyxecm/helper/web.py +229 -101
- pyxecm/helper/xml.py +527 -171
- 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 +1436 -557
- pyxecm/otcs.py +7716 -3161
- pyxecm/otds.py +2150 -919
- pyxecm/otiv.py +36 -21
- pyxecm/otmm.py +1272 -325
- 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.6.dist-info → pyxecm-2.0.0.dist-info}/WHEEL +1 -1
- pyxecm-1.6.dist-info/METADATA +0 -53
- pyxecm-1.6.dist-info/RECORD +0 -32
- {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info}/top_level.txt +0 -0
pyxecm/customizer/servicenow.py
CHANGED
|
@@ -1,84 +1,54 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
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.
|
|
1
|
+
"""ServiceNow Module to interact with the ServiceNow API.
|
|
2
|
+
|
|
3
|
+
See: https://developer.servicenow.com
|
|
37
4
|
"""
|
|
38
5
|
|
|
39
6
|
__author__ = "Dr. Marc Diefenbruch"
|
|
40
|
-
__copyright__ = "Copyright 2024, OpenText"
|
|
7
|
+
__copyright__ = "Copyright (C) 2024-2025, OpenText"
|
|
41
8
|
__credits__ = ["Kai-Philip Gatzweiler"]
|
|
42
9
|
__maintainer__ = "Dr. Marc Diefenbruch"
|
|
43
10
|
__email__ = "mdiefenb@opentext.com"
|
|
44
11
|
|
|
45
|
-
import os
|
|
46
12
|
import json
|
|
47
13
|
import logging
|
|
48
|
-
import
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
49
16
|
import threading
|
|
50
|
-
import traceback
|
|
51
|
-
from functools import cache
|
|
52
17
|
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from functools import cache
|
|
21
|
+
from typing import Any
|
|
53
22
|
|
|
54
23
|
import requests
|
|
55
24
|
from requests.auth import HTTPBasicAuth
|
|
56
25
|
from requests.exceptions import HTTPError, RequestException
|
|
57
|
-
from pyxecm.helper.data import Data
|
|
58
26
|
|
|
59
|
-
|
|
27
|
+
from pyxecm.helper import Data
|
|
28
|
+
|
|
29
|
+
default_logger = logging.getLogger("pyxecm.customizer.servicenow")
|
|
60
30
|
|
|
61
31
|
REQUEST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
62
32
|
|
|
63
33
|
REQUEST_TIMEOUT = 60
|
|
64
34
|
|
|
65
|
-
KNOWLEDGE_BASE_PATH = "
|
|
35
|
+
KNOWLEDGE_BASE_PATH = os.path.join(tempfile.gettempdir(), "attachments")
|
|
66
36
|
|
|
67
37
|
# ServiceNow database tables. Table names starting with "u_" are custom OpenText tables:
|
|
68
38
|
SN_TABLE_CATEGORIES = "kb_category"
|
|
69
39
|
SN_TABLE_KNOWLEDGE_BASES = "kb_knowledge_base"
|
|
70
40
|
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
|
-
)
|
|
41
|
+
SN_TABLE_KNOWLEDGE_BASE_ARTICLES_PRODUCT = "u_kb_template_product_documentation_standard"
|
|
74
42
|
SN_TABLE_RELATED_PRODUCTS = "cmdb_model"
|
|
75
43
|
SN_TABLE_PRODUCT_LINES = "u_ot_product_model"
|
|
76
44
|
SN_TABLE_PRODUCT_VERSIONS = "u_ot_product_model_version"
|
|
77
45
|
SN_TABLE_ATTACHMENTS = "sys_attachment"
|
|
78
46
|
|
|
79
47
|
|
|
80
|
-
class ServiceNow
|
|
81
|
-
"""
|
|
48
|
+
class ServiceNow:
|
|
49
|
+
"""Class used to retrieve and automate stettings in ServiceNow."""
|
|
50
|
+
|
|
51
|
+
logger: logging.Logger = default_logger
|
|
82
52
|
|
|
83
53
|
_config: dict
|
|
84
54
|
_access_token = None
|
|
@@ -86,6 +56,7 @@ class ServiceNow(object):
|
|
|
86
56
|
_data: Data = None
|
|
87
57
|
_thread_number = 3
|
|
88
58
|
_download_dir = ""
|
|
59
|
+
_product_exclusions = None
|
|
89
60
|
|
|
90
61
|
def __init__(
|
|
91
62
|
self,
|
|
@@ -98,21 +69,42 @@ class ServiceNow(object):
|
|
|
98
69
|
token_url: str = "",
|
|
99
70
|
thread_number: int = 3,
|
|
100
71
|
download_dir: str = KNOWLEDGE_BASE_PATH,
|
|
101
|
-
|
|
102
|
-
|
|
72
|
+
product_exclusions: list | None = None,
|
|
73
|
+
logger: logging.Logger = default_logger,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Initialize the Service Now object.
|
|
103
76
|
|
|
104
77
|
Args:
|
|
105
|
-
base_url (str):
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
|
|
114
101
|
"""
|
|
115
102
|
|
|
103
|
+
if logger != default_logger:
|
|
104
|
+
self.logger = logger.getChild("servicenow")
|
|
105
|
+
for logfilter in logger.filters:
|
|
106
|
+
self.logger.addFilter(logfilter)
|
|
107
|
+
|
|
116
108
|
servicenow_config = {}
|
|
117
109
|
|
|
118
110
|
# Store the credentials and parameters in a config dictionary:
|
|
@@ -129,61 +121,68 @@ class ServiceNow(object):
|
|
|
129
121
|
|
|
130
122
|
servicenow_config["restUrl"] = servicenow_config["baseUrl"] + "/api/now/"
|
|
131
123
|
servicenow_config["tableUrl"] = servicenow_config["restUrl"] + "table"
|
|
132
|
-
servicenow_config["knowledgeUrl"] =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
servicenow_config["
|
|
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
|
-
)
|
|
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"
|
|
144
128
|
servicenow_config["statsUrl"] = servicenow_config["restUrl"] + "stats"
|
|
145
129
|
|
|
146
130
|
self._config = servicenow_config
|
|
147
131
|
|
|
148
132
|
self._session = requests.Session()
|
|
149
133
|
|
|
150
|
-
self._data = Data()
|
|
134
|
+
self._data = Data(logger=self.logger)
|
|
151
135
|
|
|
152
136
|
self._thread_number = thread_number
|
|
153
|
-
|
|
154
137
|
self._download_dir = download_dir
|
|
138
|
+
self._product_exclusions = product_exclusions
|
|
155
139
|
|
|
156
140
|
# end method definition
|
|
157
141
|
|
|
158
|
-
def thread_wrapper(self, target, *args, **kwargs):
|
|
159
|
-
"""
|
|
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
|
+
"""
|
|
160
154
|
|
|
161
155
|
try:
|
|
162
156
|
target(*args, **kwargs)
|
|
163
|
-
except Exception
|
|
157
|
+
except Exception:
|
|
164
158
|
thread_name = threading.current_thread().name
|
|
165
|
-
logger.error(
|
|
166
|
-
"Thread '%s': failed
|
|
159
|
+
self.logger.error(
|
|
160
|
+
"Thread '%s': failed!",
|
|
161
|
+
thread_name,
|
|
167
162
|
)
|
|
168
|
-
logger.error(traceback.format_exc())
|
|
169
163
|
|
|
170
164
|
# end method definition
|
|
171
165
|
|
|
172
166
|
def config(self) -> dict:
|
|
173
|
-
"""
|
|
167
|
+
"""Return the configuration dictionary.
|
|
174
168
|
|
|
175
169
|
Returns:
|
|
176
|
-
dict:
|
|
170
|
+
dict:
|
|
171
|
+
The configuration dictionary.
|
|
172
|
+
|
|
177
173
|
"""
|
|
174
|
+
|
|
178
175
|
return self._config
|
|
179
176
|
|
|
180
177
|
# end method definition
|
|
181
178
|
|
|
182
179
|
def get_data(self) -> Data:
|
|
183
|
-
"""Get the Data object that holds all processed Knowledge base Articles
|
|
180
|
+
"""Get the Data object that holds all processed Knowledge base Articles.
|
|
184
181
|
|
|
185
182
|
Returns:
|
|
186
|
-
Data:
|
|
183
|
+
Data:
|
|
184
|
+
Data object (with embedded data frame) holding all processed articles.
|
|
185
|
+
|
|
187
186
|
"""
|
|
188
187
|
|
|
189
188
|
return self._data
|
|
@@ -191,17 +190,22 @@ class ServiceNow(object):
|
|
|
191
190
|
# end method definition
|
|
192
191
|
|
|
193
192
|
def request_header(self, content_type: str = "") -> dict:
|
|
194
|
-
"""
|
|
195
|
-
|
|
193
|
+
"""Return the request header used for Application calls.
|
|
194
|
+
|
|
195
|
+
Consists of Bearer access token and Content Type.
|
|
196
196
|
|
|
197
197
|
Args:
|
|
198
|
-
content_type (str, optional):
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
203
205
|
Return:
|
|
204
|
-
dict:
|
|
206
|
+
dict:
|
|
207
|
+
The request header values.
|
|
208
|
+
|
|
205
209
|
"""
|
|
206
210
|
|
|
207
211
|
request_header = {}
|
|
@@ -224,43 +228,48 @@ class ServiceNow(object):
|
|
|
224
228
|
additional_error_message: str = "",
|
|
225
229
|
show_error: bool = True,
|
|
226
230
|
) -> dict | None:
|
|
227
|
-
"""
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
232
237
|
|
|
233
238
|
Args:
|
|
234
|
-
response_object (object):
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
|
|
239
248
|
Returns:
|
|
240
|
-
dict
|
|
249
|
+
dict | None:
|
|
250
|
+
Response information or None in case of an error.
|
|
251
|
+
|
|
241
252
|
"""
|
|
242
253
|
|
|
243
254
|
if not response_object:
|
|
244
255
|
return None
|
|
245
256
|
|
|
246
257
|
try:
|
|
247
|
-
if response_object.text
|
|
248
|
-
dict_object = json.loads(response_object.text)
|
|
249
|
-
else:
|
|
250
|
-
dict_object = vars(response_object)
|
|
258
|
+
dict_object = json.loads(response_object.text) if response_object.text else vars(response_object)
|
|
251
259
|
except json.JSONDecodeError as exception:
|
|
252
260
|
if additional_error_message:
|
|
253
261
|
message = "Cannot decode response as JSON. {}; error -> {}".format(
|
|
254
|
-
additional_error_message,
|
|
262
|
+
additional_error_message,
|
|
263
|
+
exception,
|
|
255
264
|
)
|
|
256
265
|
else:
|
|
257
266
|
message = "Cannot decode response as JSON; error -> {}".format(
|
|
258
|
-
exception
|
|
267
|
+
exception,
|
|
259
268
|
)
|
|
260
269
|
if show_error:
|
|
261
|
-
logger.error(message)
|
|
270
|
+
self.logger.error(message)
|
|
262
271
|
else:
|
|
263
|
-
logger.warning(message)
|
|
272
|
+
self.logger.warning(message)
|
|
264
273
|
return None
|
|
265
274
|
else:
|
|
266
275
|
return dict_object
|
|
@@ -271,11 +280,17 @@ class ServiceNow(object):
|
|
|
271
280
|
"""Check existence of key / value pair in the response properties of an ServiceNow API call.
|
|
272
281
|
|
|
273
282
|
Args:
|
|
274
|
-
response (dict):
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
|
|
277
290
|
Returns:
|
|
278
|
-
bool:
|
|
291
|
+
bool:
|
|
292
|
+
True if the value was found, False otherwise.
|
|
293
|
+
|
|
279
294
|
"""
|
|
280
295
|
|
|
281
296
|
if not response:
|
|
@@ -290,7 +305,7 @@ class ServiceNow(object):
|
|
|
290
305
|
if value == record[key]:
|
|
291
306
|
return True
|
|
292
307
|
else:
|
|
293
|
-
if not
|
|
308
|
+
if key not in response:
|
|
294
309
|
return False
|
|
295
310
|
if value == response[key]:
|
|
296
311
|
return True
|
|
@@ -308,16 +323,22 @@ class ServiceNow(object):
|
|
|
308
323
|
"""Get value of a result property with a given key of an ServiceNow API call.
|
|
309
324
|
|
|
310
325
|
Args:
|
|
311
|
-
response (dict):
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
|
|
315
334
|
Returns:
|
|
316
|
-
str:
|
|
335
|
+
str:
|
|
336
|
+
The value for the key, None otherwise.
|
|
337
|
+
|
|
317
338
|
"""
|
|
318
339
|
|
|
319
340
|
# ServiceNow responses should always have a "result":
|
|
320
|
-
if not response or
|
|
341
|
+
if not response or "result" not in response:
|
|
321
342
|
return None
|
|
322
343
|
|
|
323
344
|
values = response["result"]
|
|
@@ -331,7 +352,7 @@ class ServiceNow(object):
|
|
|
331
352
|
elif isinstance(values, dict) and key in values:
|
|
332
353
|
value = values[key]
|
|
333
354
|
else:
|
|
334
|
-
logger.error("Illegal data type in ServiceNow response!")
|
|
355
|
+
self.logger.error("Illegal data type in ServiceNow response!")
|
|
335
356
|
return None
|
|
336
357
|
|
|
337
358
|
return value
|
|
@@ -342,9 +363,12 @@ class ServiceNow(object):
|
|
|
342
363
|
"""Authenticate at ServiceNow with client ID and client secret or with basic authentication.
|
|
343
364
|
|
|
344
365
|
Args:
|
|
345
|
-
auth_type (str):
|
|
366
|
+
auth_type (str):
|
|
367
|
+
The Authorization type. This can be "basic" or "oauth".
|
|
368
|
+
|
|
346
369
|
Returns:
|
|
347
|
-
str:
|
|
370
|
+
str:
|
|
371
|
+
The session token or None in case of an error.
|
|
348
372
|
|
|
349
373
|
"""
|
|
350
374
|
|
|
@@ -363,16 +387,18 @@ class ServiceNow(object):
|
|
|
363
387
|
|
|
364
388
|
return token
|
|
365
389
|
else:
|
|
366
|
-
logger.error("Unsupported authentication type")
|
|
390
|
+
self.logger.error("Unsupported authentication type")
|
|
367
391
|
return None
|
|
368
392
|
|
|
369
393
|
# end method definition
|
|
370
394
|
|
|
371
395
|
def get_oauth_token(self) -> str:
|
|
372
|
-
"""
|
|
396
|
+
"""Return the OAuth access token.
|
|
373
397
|
|
|
374
398
|
Returns:
|
|
375
|
-
str:
|
|
399
|
+
str:
|
|
400
|
+
The access token.
|
|
401
|
+
|
|
376
402
|
"""
|
|
377
403
|
|
|
378
404
|
token_post_body = {
|
|
@@ -394,9 +420,9 @@ class ServiceNow(object):
|
|
|
394
420
|
else:
|
|
395
421
|
# Store authentication access_token:
|
|
396
422
|
self._access_token = authenticate_dict["access_token"]
|
|
397
|
-
logger.debug("Access Token -> %s", self._access_token)
|
|
423
|
+
self.logger.debug("Access Token -> %s", self._access_token)
|
|
398
424
|
else:
|
|
399
|
-
logger.error(
|
|
425
|
+
self.logger.error(
|
|
400
426
|
"Failed to request an Service Now Access Token; error -> %s",
|
|
401
427
|
response.text,
|
|
402
428
|
)
|
|
@@ -408,57 +434,59 @@ class ServiceNow(object):
|
|
|
408
434
|
|
|
409
435
|
@cache
|
|
410
436
|
def get_object(self, table_name: str, sys_id: str) -> dict | None:
|
|
411
|
-
"""Get an ServiceNow object based on table name and ID
|
|
437
|
+
"""Get an ServiceNow object based on table name and ID.
|
|
412
438
|
|
|
413
439
|
Args:
|
|
414
|
-
table_name (str):
|
|
415
|
-
|
|
440
|
+
table_name (str):
|
|
441
|
+
The name of the ServiceNow table.
|
|
442
|
+
sys_id (str):
|
|
443
|
+
The ID of the data set to resolve.
|
|
416
444
|
|
|
417
445
|
Returns:
|
|
418
|
-
dict | None:
|
|
419
|
-
|
|
446
|
+
dict | None:
|
|
447
|
+
The dictionary of fields of resulting table row or None
|
|
448
|
+
in case an error occured.
|
|
449
|
+
|
|
420
450
|
"""
|
|
421
451
|
|
|
422
452
|
if not table_name:
|
|
423
|
-
logger.error("Table name is missing!")
|
|
453
|
+
self.logger.error("Table name is missing!")
|
|
424
454
|
return None
|
|
425
455
|
|
|
426
456
|
if not sys_id:
|
|
427
|
-
logger.error("System ID of item to lookup is missing!")
|
|
457
|
+
self.logger.error("System ID of item to lookup is missing!")
|
|
428
458
|
return None
|
|
429
459
|
|
|
430
460
|
request_header = self.request_header()
|
|
431
461
|
|
|
432
462
|
request_url = self.config()["restUrl"] + "table/{}/{}".format(
|
|
433
|
-
table_name,
|
|
463
|
+
table_name,
|
|
464
|
+
sys_id,
|
|
434
465
|
)
|
|
435
466
|
|
|
436
467
|
try:
|
|
437
468
|
response = self._session.get(url=request_url, headers=request_header)
|
|
438
469
|
data = self.parse_request_response(response)
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
logger.error(
|
|
443
|
-
"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'!",
|
|
444
473
|
sys_id,
|
|
445
474
|
table_name,
|
|
446
|
-
str(http_err),
|
|
447
475
|
)
|
|
448
|
-
except RequestException
|
|
449
|
-
logger.error(
|
|
450
|
-
"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'!",
|
|
451
479
|
sys_id,
|
|
452
480
|
table_name,
|
|
453
|
-
str(req_err),
|
|
454
481
|
)
|
|
455
|
-
except Exception
|
|
456
|
-
logger.error(
|
|
457
|
-
"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'!",
|
|
458
485
|
sys_id,
|
|
459
486
|
table_name,
|
|
460
|
-
str(err),
|
|
461
487
|
)
|
|
488
|
+
else:
|
|
489
|
+
return data
|
|
462
490
|
|
|
463
491
|
return None
|
|
464
492
|
|
|
@@ -468,10 +496,13 @@ class ServiceNow(object):
|
|
|
468
496
|
"""Get summary object for an article.
|
|
469
497
|
|
|
470
498
|
Args:
|
|
471
|
-
summary_sys_id (str):
|
|
499
|
+
summary_sys_id (str):
|
|
500
|
+
The system ID of the article.
|
|
472
501
|
|
|
473
502
|
Returns:
|
|
474
|
-
dict | None:
|
|
503
|
+
dict | None:
|
|
504
|
+
The dictionary with the summary.
|
|
505
|
+
|
|
475
506
|
"""
|
|
476
507
|
|
|
477
508
|
return self.get_object(table_name="kb_knowledge_summary", sys_id=summary_sys_id)
|
|
@@ -490,17 +521,24 @@ class ServiceNow(object):
|
|
|
490
521
|
"""Retrieve a specified ServiceNow table data (row or values).
|
|
491
522
|
|
|
492
523
|
Args:
|
|
493
|
-
table_name (str):
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
501
537
|
|
|
502
538
|
Returns:
|
|
503
|
-
list | None:
|
|
539
|
+
list | None:
|
|
540
|
+
List or articles or None if the request fails.
|
|
541
|
+
|
|
504
542
|
"""
|
|
505
543
|
|
|
506
544
|
request_header = self.request_header()
|
|
@@ -519,32 +557,34 @@ class ServiceNow(object):
|
|
|
519
557
|
encoded_query = urllib.parse.urlencode(params, doseq=True)
|
|
520
558
|
|
|
521
559
|
request_url = self.config()["tableUrl"] + "/{}?{}".format(
|
|
522
|
-
table_name,
|
|
560
|
+
table_name,
|
|
561
|
+
encoded_query,
|
|
523
562
|
)
|
|
524
563
|
|
|
525
564
|
try:
|
|
526
565
|
while True:
|
|
527
566
|
response = self._session.get(
|
|
528
|
-
url=request_url,
|
|
567
|
+
url=request_url,
|
|
568
|
+
headers=request_header, # , params=params
|
|
529
569
|
)
|
|
530
570
|
data = self.parse_request_response(response)
|
|
531
571
|
|
|
532
572
|
if response.status_code == 200:
|
|
533
573
|
return data.get("result", [])
|
|
534
574
|
elif response.status_code == 202:
|
|
535
|
-
logger.warning(
|
|
536
|
-
"Service Now returned <202 Accepted> -> throtteling, retrying ..."
|
|
575
|
+
self.logger.warning(
|
|
576
|
+
"Service Now returned <202 Accepted> -> throtteling, retrying ...",
|
|
537
577
|
)
|
|
538
578
|
time.sleep(1000)
|
|
539
579
|
else:
|
|
540
580
|
return None
|
|
541
581
|
|
|
542
|
-
except HTTPError
|
|
543
|
-
logger.error("%sHTTP error
|
|
544
|
-
except RequestException
|
|
545
|
-
logger.error("%sRequest error
|
|
546
|
-
except Exception
|
|
547
|
-
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)
|
|
548
588
|
|
|
549
589
|
return None
|
|
550
590
|
|
|
@@ -555,15 +595,20 @@ class ServiceNow(object):
|
|
|
555
595
|
table_name: str,
|
|
556
596
|
query: str | None = None,
|
|
557
597
|
) -> int:
|
|
558
|
-
"""Get number of table rows (e.g. Knowledge Base Articles) matching the query
|
|
559
|
-
|
|
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).
|
|
560
601
|
|
|
561
602
|
Args:
|
|
562
|
-
table_name (str):
|
|
563
|
-
|
|
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 "".
|
|
564
607
|
|
|
565
608
|
Returns:
|
|
566
|
-
int:
|
|
609
|
+
int:
|
|
610
|
+
Number of table rows.
|
|
611
|
+
|
|
567
612
|
"""
|
|
568
613
|
|
|
569
614
|
request_header = self.request_header()
|
|
@@ -576,21 +621,24 @@ class ServiceNow(object):
|
|
|
576
621
|
encoded_query = urllib.parse.urlencode(params, doseq=True)
|
|
577
622
|
|
|
578
623
|
request_url = self.config()["statsUrl"] + "/{}?{}".format(
|
|
579
|
-
table_name,
|
|
624
|
+
table_name,
|
|
625
|
+
encoded_query,
|
|
580
626
|
)
|
|
581
627
|
|
|
582
628
|
try:
|
|
583
629
|
response = self._session.get(
|
|
584
|
-
url=request_url,
|
|
630
|
+
url=request_url,
|
|
631
|
+
headers=request_header,
|
|
632
|
+
timeout=600,
|
|
585
633
|
)
|
|
586
634
|
data = self.parse_request_response(response)
|
|
587
635
|
return int(data["result"]["stats"]["count"])
|
|
588
|
-
except HTTPError
|
|
589
|
-
logger.error("HTTP error occurred
|
|
590
|
-
except RequestException
|
|
591
|
-
logger.error("Request error occurred
|
|
592
|
-
except Exception
|
|
593
|
-
logger.error("An error occurred
|
|
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!")
|
|
594
642
|
|
|
595
643
|
return None
|
|
596
644
|
|
|
@@ -600,9 +648,11 @@ class ServiceNow(object):
|
|
|
600
648
|
"""Get the configured knowledge base categories in ServiceNow.
|
|
601
649
|
|
|
602
650
|
Returns:
|
|
603
|
-
list | None:
|
|
651
|
+
list | None:
|
|
652
|
+
A list of configured knowledge base categories
|
|
653
|
+
or None in case of an error.
|
|
604
654
|
|
|
605
|
-
|
|
655
|
+
Example:
|
|
606
656
|
[
|
|
607
657
|
{
|
|
608
658
|
'sys_mod_count': '2',
|
|
@@ -628,6 +678,7 @@ class ServiceNow(object):
|
|
|
628
678
|
'sys_created_by': 'tiychowdhury@opentext.com'
|
|
629
679
|
}
|
|
630
680
|
]
|
|
681
|
+
|
|
631
682
|
"""
|
|
632
683
|
|
|
633
684
|
return self.get_table(
|
|
@@ -642,9 +693,10 @@ class ServiceNow(object):
|
|
|
642
693
|
"""Get the configured knowledge bases in ServiceNow.
|
|
643
694
|
|
|
644
695
|
Returns:
|
|
645
|
-
list | None:
|
|
696
|
+
list | None:
|
|
697
|
+
The list of configured knowledge bases or None in case of an error.
|
|
646
698
|
|
|
647
|
-
|
|
699
|
+
Example:
|
|
648
700
|
[
|
|
649
701
|
{
|
|
650
702
|
'mandatory_fields': '',
|
|
@@ -694,10 +746,11 @@ class ServiceNow(object):
|
|
|
694
746
|
'card_color': '',
|
|
695
747
|
'disable_rating': 'false',
|
|
696
748
|
'create_translation_task': 'false',
|
|
697
|
-
'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec
|
|
749
|
+
'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec'
|
|
698
750
|
},
|
|
699
751
|
...
|
|
700
752
|
]
|
|
753
|
+
|
|
701
754
|
"""
|
|
702
755
|
|
|
703
756
|
return self.get_table(
|
|
@@ -715,20 +768,26 @@ class ServiceNow(object):
|
|
|
715
768
|
limit: int | None = 10,
|
|
716
769
|
offset: int = 0,
|
|
717
770
|
) -> list | None:
|
|
718
|
-
"""Get selected / filtered Knowledge Base articles
|
|
771
|
+
"""Get selected / filtered Knowledge Base articles.
|
|
719
772
|
|
|
720
773
|
Args:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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).
|
|
727
785
|
|
|
728
786
|
Returns:
|
|
729
|
-
list | None:
|
|
787
|
+
list | None:
|
|
788
|
+
List or articles or None if the request fails.
|
|
730
789
|
|
|
731
|
-
|
|
790
|
+
Example:
|
|
732
791
|
[
|
|
733
792
|
{
|
|
734
793
|
'parent': '',
|
|
@@ -832,6 +891,7 @@ class ServiceNow(object):
|
|
|
832
891
|
},
|
|
833
892
|
...
|
|
834
893
|
]
|
|
894
|
+
|
|
835
895
|
"""
|
|
836
896
|
|
|
837
897
|
return self.get_table(
|
|
@@ -845,13 +905,16 @@ class ServiceNow(object):
|
|
|
845
905
|
|
|
846
906
|
# end method definition
|
|
847
907
|
|
|
848
|
-
def make_file_names_unique(self, file_list: list):
|
|
849
|
-
"""Make file names unique if required.
|
|
850
|
-
|
|
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".
|
|
851
912
|
|
|
852
913
|
Args:
|
|
853
|
-
file_list (list):
|
|
854
|
-
|
|
914
|
+
file_list (list):
|
|
915
|
+
List of attachments as dictionaries
|
|
916
|
+
with "sys_id" and "file_name" keys.
|
|
917
|
+
|
|
855
918
|
"""
|
|
856
919
|
|
|
857
920
|
# Dictionary to keep track of how many times each file name has been encountered
|
|
@@ -882,13 +945,16 @@ class ServiceNow(object):
|
|
|
882
945
|
# end method definition
|
|
883
946
|
|
|
884
947
|
def get_article_attachments(self, article: dict) -> list | None:
|
|
885
|
-
"""Get a list of attachments for an article
|
|
948
|
+
"""Get a list of attachments for an article.
|
|
886
949
|
|
|
887
950
|
Args:
|
|
888
|
-
article (dict):
|
|
951
|
+
article (dict):
|
|
952
|
+
Article information.
|
|
889
953
|
|
|
890
954
|
Returns:
|
|
891
|
-
list | None:
|
|
955
|
+
list | None:
|
|
956
|
+
List of attachments for the article.
|
|
957
|
+
|
|
892
958
|
"""
|
|
893
959
|
|
|
894
960
|
article_sys_id = article["sys_id"]
|
|
@@ -904,30 +970,32 @@ class ServiceNow(object):
|
|
|
904
970
|
|
|
905
971
|
try:
|
|
906
972
|
response = self._session.get(
|
|
907
|
-
url=request_url,
|
|
973
|
+
url=request_url,
|
|
974
|
+
headers=request_header,
|
|
975
|
+
params=params,
|
|
908
976
|
)
|
|
909
977
|
data = self.parse_request_response(response)
|
|
910
978
|
attachments = data.get("result", [])
|
|
911
979
|
if not attachments:
|
|
912
|
-
logger.debug(
|
|
980
|
+
self.logger.debug(
|
|
913
981
|
"Knowledge base article -> %s does not have attachments!",
|
|
914
982
|
article_number,
|
|
915
983
|
)
|
|
916
984
|
return []
|
|
917
985
|
else:
|
|
918
|
-
logger.
|
|
986
|
+
self.logger.debug(
|
|
919
987
|
"Knowledge base article -> %s has %s attachments.",
|
|
920
988
|
article_number,
|
|
921
989
|
len(attachments),
|
|
922
990
|
)
|
|
923
991
|
return attachments
|
|
924
992
|
|
|
925
|
-
except HTTPError
|
|
926
|
-
logger.error("HTTP error occurred
|
|
927
|
-
except RequestException
|
|
928
|
-
logger.error("Request error occurred
|
|
929
|
-
except Exception
|
|
930
|
-
logger.error("An error occurred
|
|
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!")
|
|
931
999
|
|
|
932
1000
|
return None
|
|
933
1001
|
|
|
@@ -941,26 +1009,30 @@ class ServiceNow(object):
|
|
|
941
1009
|
"""Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
|
|
942
1010
|
|
|
943
1011
|
Args:
|
|
944
|
-
article (dict):
|
|
945
|
-
|
|
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.
|
|
946
1016
|
|
|
947
1017
|
Returns:
|
|
948
|
-
bool:
|
|
1018
|
+
bool:
|
|
1019
|
+
True = success, False = failure.
|
|
1020
|
+
|
|
949
1021
|
"""
|
|
950
1022
|
|
|
951
1023
|
article_number = article["number"]
|
|
952
1024
|
|
|
953
|
-
attachments = self.get_article_attachments(article)
|
|
1025
|
+
attachments = self.get_article_attachments(article=article)
|
|
954
1026
|
|
|
955
1027
|
if not attachments:
|
|
956
|
-
logger.debug(
|
|
1028
|
+
self.logger.debug(
|
|
957
1029
|
"Knowledge base article -> %s does not have attachments to download!",
|
|
958
1030
|
article_number,
|
|
959
1031
|
)
|
|
960
1032
|
article["has_attachments"] = False
|
|
961
1033
|
return False
|
|
962
1034
|
else:
|
|
963
|
-
logger.info(
|
|
1035
|
+
self.logger.info(
|
|
964
1036
|
"Knowledge base article -> %s has %s attachments to download...",
|
|
965
1037
|
article_number,
|
|
966
1038
|
len(attachments),
|
|
@@ -980,79 +1052,108 @@ class ServiceNow(object):
|
|
|
980
1052
|
article["download_files_ids"] = []
|
|
981
1053
|
|
|
982
1054
|
if not os.path.exists(base_dir):
|
|
983
|
-
|
|
1055
|
+
try:
|
|
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
|
|
984
1071
|
|
|
985
1072
|
for attachment in attachments:
|
|
986
1073
|
file_path = os.path.join(base_dir, attachment["file_name"])
|
|
987
1074
|
|
|
988
1075
|
if os.path.exists(file_path) and skip_existing:
|
|
989
|
-
logger.info(
|
|
990
|
-
"File -> %s has been downloaded before. Skipping download...",
|
|
1076
|
+
self.logger.info(
|
|
1077
|
+
"File -> '%s' has been downloaded before. Skipping download...",
|
|
991
1078
|
file_path,
|
|
992
1079
|
)
|
|
993
1080
|
|
|
994
|
-
#
|
|
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:
|
|
995
1085
|
article["download_files"].append(attachment["file_name"])
|
|
996
1086
|
article["download_files_ids"].append(attachment["sys_id"])
|
|
997
1087
|
continue
|
|
998
|
-
attachment_download_url = (
|
|
999
|
-
self.config()["attachmentDownloadUrl"]
|
|
1000
|
-
+ "/"
|
|
1001
|
-
+ attachment["sys_id"]
|
|
1002
|
-
+ "/file"
|
|
1003
|
-
)
|
|
1088
|
+
attachment_download_url = self.config()["attachmentDownloadUrl"] + "/" + attachment["sys_id"] + "/file"
|
|
1004
1089
|
try:
|
|
1005
|
-
logger.info(
|
|
1006
|
-
"Downloading attachment file -> '%s' for article -> %s from ServiceNow...",
|
|
1090
|
+
self.logger.info(
|
|
1091
|
+
"Downloading attachment file -> '%s' for article -> '%s' from ServiceNow...",
|
|
1007
1092
|
file_path,
|
|
1008
1093
|
article_number,
|
|
1009
1094
|
)
|
|
1010
1095
|
|
|
1096
|
+
# Request the attachment as a stream from ServiceNow.
|
|
1097
|
+
# This initiates the download process...
|
|
1011
1098
|
attachment_response = self._session.get(
|
|
1012
|
-
attachment_download_url,
|
|
1099
|
+
attachment_download_url,
|
|
1100
|
+
stream=True,
|
|
1013
1101
|
)
|
|
1014
1102
|
attachment_response.raise_for_status()
|
|
1015
1103
|
|
|
1016
|
-
|
|
1104
|
+
# Read and write the attachment file in chunks:
|
|
1105
|
+
with open(file_path, "wb") as attachment_file:
|
|
1017
1106
|
for chunk in attachment_response.iter_content(chunk_size=8192):
|
|
1018
|
-
|
|
1107
|
+
attachment_file.write(chunk)
|
|
1019
1108
|
|
|
1020
|
-
#
|
|
1021
|
-
#
|
|
1109
|
+
# We build a list of filenames and IDs.
|
|
1110
|
+
# The IDs we want to use as nicknames later on.
|
|
1022
1111
|
article["download_files"].append(attachment["file_name"])
|
|
1023
1112
|
article["download_files_ids"].append(attachment["sys_id"])
|
|
1024
1113
|
|
|
1025
|
-
except HTTPError
|
|
1026
|
-
logger.error(
|
|
1027
|
-
"Failed to download -> '%s' using url -> %s
|
|
1114
|
+
except HTTPError:
|
|
1115
|
+
self.logger.error(
|
|
1116
|
+
"Failed to download -> '%s' using url -> %s",
|
|
1028
1117
|
attachment["file_name"],
|
|
1029
1118
|
attachment_download_url,
|
|
1030
|
-
str(e),
|
|
1031
1119
|
)
|
|
1032
1120
|
|
|
1033
1121
|
return True
|
|
1034
1122
|
|
|
1035
1123
|
# end method definition
|
|
1036
1124
|
|
|
1037
|
-
def load_articles(
|
|
1038
|
-
|
|
1039
|
-
|
|
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.
|
|
1040
1132
|
|
|
1041
1133
|
Args:
|
|
1042
|
-
|
|
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.
|
|
1043
1141
|
|
|
1044
1142
|
Returns:
|
|
1045
|
-
bool:
|
|
1143
|
+
bool:
|
|
1144
|
+
True = Success, False = Failure.
|
|
1145
|
+
|
|
1046
1146
|
"""
|
|
1047
1147
|
|
|
1048
1148
|
total_count = self.get_table_count(table_name=table_name, query=query)
|
|
1049
1149
|
|
|
1050
|
-
logger.info(
|
|
1051
|
-
"Total number of Knowledge Base Articles (KBA) -> %s",
|
|
1150
|
+
self.logger.info(
|
|
1151
|
+
"Total number of Knowledge Base Articles (KBA) -> %s",
|
|
1152
|
+
str(total_count),
|
|
1052
1153
|
)
|
|
1053
1154
|
|
|
1054
1155
|
if total_count == 0:
|
|
1055
|
-
logger.info(
|
|
1156
|
+
self.logger.info(
|
|
1056
1157
|
"Query does not return any value from ServiceNow table -> '%s'. Finishing.",
|
|
1057
1158
|
table_name,
|
|
1058
1159
|
)
|
|
@@ -1068,7 +1169,7 @@ class ServiceNow(object):
|
|
|
1068
1169
|
remainder = 0
|
|
1069
1170
|
number = 1
|
|
1070
1171
|
|
|
1071
|
-
logger.info(
|
|
1172
|
+
self.logger.info(
|
|
1072
1173
|
"Processing -> %s Knowledge Base Articles (KBA), table name -> '%s', thread number -> %s, partition size -> %s",
|
|
1073
1174
|
str(total_count),
|
|
1074
1175
|
table_name,
|
|
@@ -1082,7 +1183,7 @@ class ServiceNow(object):
|
|
|
1082
1183
|
for i in range(number):
|
|
1083
1184
|
current_partition_size = partition_size + (1 if i < remainder else 0)
|
|
1084
1185
|
thread = threading.Thread(
|
|
1085
|
-
name=f"load_articles_{i+1:02}",
|
|
1186
|
+
name=f"load_articles_{i + 1:02}",
|
|
1086
1187
|
target=self.thread_wrapper,
|
|
1087
1188
|
args=(
|
|
1088
1189
|
self.load_articles_worker,
|
|
@@ -1090,6 +1191,7 @@ class ServiceNow(object):
|
|
|
1090
1191
|
query,
|
|
1091
1192
|
current_partition_size,
|
|
1092
1193
|
current_offset,
|
|
1194
|
+
skip_existing_downloads,
|
|
1093
1195
|
),
|
|
1094
1196
|
)
|
|
1095
1197
|
thread.start()
|
|
@@ -1104,17 +1206,31 @@ class ServiceNow(object):
|
|
|
1104
1206
|
# end method definition
|
|
1105
1207
|
|
|
1106
1208
|
def load_articles_worker(
|
|
1107
|
-
self,
|
|
1209
|
+
self,
|
|
1210
|
+
table_name: str,
|
|
1211
|
+
query: str,
|
|
1212
|
+
partition_size: int,
|
|
1213
|
+
partition_offset: int,
|
|
1214
|
+
skip_existing_downloads: bool = True,
|
|
1108
1215
|
) -> None:
|
|
1109
|
-
"""Worker
|
|
1216
|
+
"""Worker method for multi-threading.
|
|
1110
1217
|
|
|
1111
1218
|
Args:
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
+
|
|
1115
1231
|
"""
|
|
1116
1232
|
|
|
1117
|
-
logger.info(
|
|
1233
|
+
self.logger.info(
|
|
1118
1234
|
"Start processing KBAs in range from -> %s to -> %s from table -> '%s'...",
|
|
1119
1235
|
partition_offset,
|
|
1120
1236
|
partition_offset + partition_size,
|
|
@@ -1125,23 +1241,29 @@ class ServiceNow(object):
|
|
|
1125
1241
|
# So we define "limit" as the maximum number of KBAs we want to retrieve for one REST call.
|
|
1126
1242
|
# This should be a reasonable number to avoid timeouts. We also need to make sure
|
|
1127
1243
|
# the limit is not bigger than the the partition size:
|
|
1128
|
-
limit =
|
|
1244
|
+
limit = min(partition_size, 100)
|
|
1129
1245
|
|
|
1130
1246
|
for offset in range(partition_offset, partition_offset + partition_size, limit):
|
|
1131
1247
|
articles = self.get_table(
|
|
1132
|
-
table_name=table_name,
|
|
1248
|
+
table_name=table_name,
|
|
1249
|
+
query=query,
|
|
1250
|
+
limit=limit,
|
|
1251
|
+
offset=offset,
|
|
1133
1252
|
)
|
|
1134
|
-
logger.info(
|
|
1253
|
+
self.logger.info(
|
|
1135
1254
|
"Retrieved a list of %s KBAs starting at offset -> %s to process.",
|
|
1136
1255
|
str(len(articles)),
|
|
1137
1256
|
offset,
|
|
1138
1257
|
)
|
|
1139
1258
|
for article in articles:
|
|
1140
|
-
logger.info("Processing KBA -> %s...", article["number"])
|
|
1259
|
+
self.logger.info("Processing KBA -> %s...", article["number"])
|
|
1141
1260
|
article["source_table"] = table_name
|
|
1142
|
-
self.load_article(
|
|
1261
|
+
self.load_article(
|
|
1262
|
+
article=article,
|
|
1263
|
+
skip_existing_downloads=skip_existing_downloads,
|
|
1264
|
+
)
|
|
1143
1265
|
|
|
1144
|
-
logger.info(
|
|
1266
|
+
self.logger.info(
|
|
1145
1267
|
"Finished processing KBAs in range from -> %s to -> %s from table -> '%s'.",
|
|
1146
1268
|
partition_offset,
|
|
1147
1269
|
partition_offset + partition_size,
|
|
@@ -1150,15 +1272,19 @@ class ServiceNow(object):
|
|
|
1150
1272
|
|
|
1151
1273
|
# end method definition
|
|
1152
1274
|
|
|
1153
|
-
def load_article(self, article: dict, skip_existing_downloads: bool = True):
|
|
1154
|
-
"""Process a single KBA
|
|
1155
|
-
|
|
1156
|
-
|
|
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.
|
|
1157
1280
|
|
|
1158
1281
|
Args:
|
|
1159
|
-
article (dict):
|
|
1160
|
-
|
|
1161
|
-
|
|
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.
|
|
1162
1288
|
|
|
1163
1289
|
Side effect:
|
|
1164
1290
|
The article dict is modified with by adding additional key / value
|
|
@@ -1177,209 +1303,272 @@ class ServiceNow(object):
|
|
|
1177
1303
|
|
|
1178
1304
|
"""
|
|
1179
1305
|
|
|
1306
|
+
#
|
|
1307
|
+
# Download the attachments of the KBA:
|
|
1308
|
+
#
|
|
1309
|
+
|
|
1180
1310
|
_ = self.download_attachments(
|
|
1181
|
-
article=article,
|
|
1311
|
+
article=article,
|
|
1312
|
+
skip_existing=skip_existing_downloads,
|
|
1182
1313
|
)
|
|
1183
1314
|
|
|
1184
1315
|
#
|
|
1185
1316
|
# Add additional columns from related ServiceNow tables:
|
|
1186
1317
|
#
|
|
1187
1318
|
|
|
1188
|
-
if
|
|
1319
|
+
if article.get("kb_category"):
|
|
1189
1320
|
category_key = article.get("kb_category")["value"]
|
|
1190
1321
|
category_table_name = SN_TABLE_CATEGORIES
|
|
1191
1322
|
category = self.get_object(
|
|
1192
|
-
table_name=category_table_name,
|
|
1323
|
+
table_name=category_table_name,
|
|
1324
|
+
sys_id=category_key,
|
|
1193
1325
|
)
|
|
1194
1326
|
if category:
|
|
1195
1327
|
article["kb_category_name"] = self.get_result_value(
|
|
1196
|
-
response=category,
|
|
1328
|
+
response=category,
|
|
1329
|
+
key="full_category",
|
|
1197
1330
|
)
|
|
1198
1331
|
else:
|
|
1199
|
-
logger.warning(
|
|
1200
|
-
"Article -> %s has no category value!",
|
|
1332
|
+
self.logger.warning(
|
|
1333
|
+
"Article -> %s has no category value!",
|
|
1334
|
+
article["number"],
|
|
1201
1335
|
)
|
|
1202
1336
|
article["kb_category_name"] = ""
|
|
1203
1337
|
else:
|
|
1204
|
-
logger.warning(
|
|
1205
|
-
"Article -> %s has no value for category!",
|
|
1338
|
+
self.logger.warning(
|
|
1339
|
+
"Article -> %s has no value for category!",
|
|
1340
|
+
article["number"],
|
|
1206
1341
|
)
|
|
1207
1342
|
article["kb_category_name"] = ""
|
|
1208
1343
|
|
|
1209
1344
|
knowledge_base_key = article.get("kb_knowledge_base")["value"]
|
|
1210
1345
|
knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
|
|
1211
1346
|
knowledge_base = self.get_object(
|
|
1212
|
-
table_name=knowledge_base_table_name,
|
|
1347
|
+
table_name=knowledge_base_table_name,
|
|
1348
|
+
sys_id=knowledge_base_key,
|
|
1213
1349
|
)
|
|
1214
1350
|
if knowledge_base:
|
|
1215
1351
|
article["kb_knowledge_base_name"] = self.get_result_value(
|
|
1216
|
-
response=knowledge_base,
|
|
1352
|
+
response=knowledge_base,
|
|
1353
|
+
key="title",
|
|
1217
1354
|
)
|
|
1218
1355
|
else:
|
|
1219
|
-
logger.warning(
|
|
1220
|
-
"Article -> %s has no value for
|
|
1356
|
+
self.logger.warning(
|
|
1357
|
+
"Article -> %s has no value for knowledge base!",
|
|
1221
1358
|
article["number"],
|
|
1222
1359
|
)
|
|
1223
1360
|
article["kb_knowledge_base_name"] = ""
|
|
1224
1361
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1362
|
+
# We use a set to make sure the resulting related items are unique:
|
|
1363
|
+
related_product_names: set = set()
|
|
1364
|
+
if article.get("related_products"):
|
|
1227
1365
|
related_product_keys = article.get("related_products").split(",")
|
|
1228
1366
|
for related_product_key in related_product_keys:
|
|
1229
1367
|
related_product = self.get_object(
|
|
1230
|
-
table_name=SN_TABLE_RELATED_PRODUCTS,
|
|
1368
|
+
table_name=SN_TABLE_RELATED_PRODUCTS,
|
|
1369
|
+
sys_id=related_product_key,
|
|
1231
1370
|
)
|
|
1232
1371
|
if related_product:
|
|
1233
1372
|
related_product_name = self.get_result_value(
|
|
1234
|
-
response=related_product,
|
|
1373
|
+
response=related_product,
|
|
1374
|
+
key="name",
|
|
1235
1375
|
)
|
|
1236
|
-
|
|
1237
|
-
|
|
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)",
|
|
1238
1387
|
related_product_name,
|
|
1239
1388
|
related_product_key,
|
|
1240
1389
|
)
|
|
1241
|
-
|
|
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)
|
|
1242
1393
|
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1243
1394
|
if len(related_product_names) == 49:
|
|
1244
|
-
logger.info(
|
|
1245
|
-
"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",
|
|
1246
1397
|
article["number"],
|
|
1247
1398
|
)
|
|
1248
1399
|
break
|
|
1249
1400
|
else:
|
|
1250
|
-
logger.warning(
|
|
1251
|
-
"Article -> %s: Cannot lookup related
|
|
1401
|
+
self.logger.warning(
|
|
1402
|
+
"Article -> %s: Cannot lookup related product name in table -> '%s' with key -> '%s'",
|
|
1252
1403
|
article["number"],
|
|
1253
1404
|
SN_TABLE_RELATED_PRODUCTS,
|
|
1254
1405
|
related_product_key,
|
|
1255
1406
|
)
|
|
1256
1407
|
else:
|
|
1257
|
-
logger.
|
|
1258
|
-
"Article -> %s has no
|
|
1408
|
+
self.logger.debug(
|
|
1409
|
+
"Article -> %s has no related products!",
|
|
1259
1410
|
article["number"],
|
|
1260
1411
|
)
|
|
1261
|
-
|
|
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)
|
|
1262
1415
|
|
|
1263
|
-
|
|
1264
|
-
|
|
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"):
|
|
1265
1419
|
product_line_keys = article.get("u_product_line").split(",")
|
|
1266
1420
|
product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1267
1421
|
for product_line_key in product_line_keys:
|
|
1268
1422
|
product_line = self.get_object(
|
|
1269
|
-
table_name=product_line_table,
|
|
1423
|
+
table_name=product_line_table,
|
|
1424
|
+
sys_id=product_line_key,
|
|
1270
1425
|
)
|
|
1271
1426
|
if product_line:
|
|
1272
1427
|
product_line_name = self.get_result_value(
|
|
1273
|
-
response=product_line,
|
|
1428
|
+
response=product_line,
|
|
1429
|
+
key="name",
|
|
1274
1430
|
)
|
|
1275
|
-
|
|
1276
|
-
|
|
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)",
|
|
1277
1435
|
product_line_name,
|
|
1278
1436
|
product_line_key,
|
|
1279
1437
|
)
|
|
1280
|
-
|
|
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)
|
|
1281
1441
|
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1282
1442
|
if len(product_line_names) == 49:
|
|
1283
|
-
logger.info(
|
|
1284
|
-
"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",
|
|
1285
1445
|
article["number"],
|
|
1286
1446
|
)
|
|
1287
1447
|
break
|
|
1448
|
+
# end if product_line:
|
|
1288
1449
|
else:
|
|
1289
|
-
logger.warning(
|
|
1290
|
-
"Article -> %s: Cannot lookup related
|
|
1450
|
+
self.logger.warning(
|
|
1451
|
+
"Article -> %s: Cannot lookup related product line name in table -> '%s' with key -> '%s'",
|
|
1291
1452
|
article["number"],
|
|
1292
1453
|
product_line_table,
|
|
1293
1454
|
product_line_key,
|
|
1294
1455
|
)
|
|
1295
1456
|
else:
|
|
1296
|
-
logger.
|
|
1297
|
-
"Article -> %s has no
|
|
1457
|
+
self.logger.debug(
|
|
1458
|
+
"Article -> %s has no related product lines!",
|
|
1298
1459
|
article["number"],
|
|
1299
1460
|
)
|
|
1300
|
-
|
|
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)
|
|
1301
1464
|
|
|
1302
|
-
|
|
1303
|
-
|
|
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"):
|
|
1304
1468
|
sub_product_line_keys = article.get("u_sub_product_line").split(",")
|
|
1305
1469
|
sub_product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1306
1470
|
for sub_product_line_key in sub_product_line_keys:
|
|
1307
1471
|
sub_product_line = self.get_object(
|
|
1308
|
-
table_name=sub_product_line_table,
|
|
1472
|
+
table_name=sub_product_line_table,
|
|
1473
|
+
sys_id=sub_product_line_key,
|
|
1309
1474
|
)
|
|
1310
1475
|
if sub_product_line:
|
|
1311
1476
|
sub_product_line_name = self.get_result_value(
|
|
1312
|
-
response=sub_product_line,
|
|
1477
|
+
response=sub_product_line,
|
|
1478
|
+
key="name",
|
|
1313
1479
|
)
|
|
1314
|
-
|
|
1315
|
-
|
|
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)",
|
|
1316
1484
|
sub_product_line_name,
|
|
1317
1485
|
sub_product_line_key,
|
|
1318
1486
|
)
|
|
1319
|
-
|
|
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)
|
|
1320
1490
|
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1321
1491
|
if len(sub_product_line_names) == 49:
|
|
1322
|
-
logger.info(
|
|
1323
|
-
"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",
|
|
1324
1494
|
article["number"],
|
|
1325
1495
|
)
|
|
1326
1496
|
break
|
|
1327
1497
|
else:
|
|
1328
|
-
logger.warning(
|
|
1329
|
-
"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'",
|
|
1330
1500
|
article["number"],
|
|
1331
1501
|
sub_product_line_table,
|
|
1332
1502
|
sub_product_line_key,
|
|
1333
1503
|
)
|
|
1334
1504
|
else:
|
|
1335
|
-
logger.
|
|
1336
|
-
"Article -> %s has no
|
|
1505
|
+
self.logger.debug(
|
|
1506
|
+
"Article -> %s has no related sub product lines!",
|
|
1337
1507
|
article["number"],
|
|
1338
1508
|
)
|
|
1339
|
-
|
|
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)
|
|
1340
1512
|
|
|
1341
|
-
|
|
1342
|
-
|
|
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"):
|
|
1343
1516
|
application_keys = article.get("u_application").split(",")
|
|
1344
1517
|
application_table_name = SN_TABLE_PRODUCT_LINES
|
|
1345
1518
|
for application_key in application_keys:
|
|
1346
1519
|
application = self.get_object(
|
|
1347
|
-
table_name=application_table_name,
|
|
1520
|
+
table_name=application_table_name,
|
|
1521
|
+
sys_id=application_key,
|
|
1348
1522
|
)
|
|
1349
1523
|
if application:
|
|
1350
1524
|
application_name = self.get_result_value(
|
|
1351
|
-
response=application,
|
|
1525
|
+
response=application,
|
|
1526
|
+
key="name",
|
|
1352
1527
|
)
|
|
1353
|
-
|
|
1354
|
-
|
|
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)",
|
|
1355
1539
|
application_name,
|
|
1356
1540
|
application_key,
|
|
1357
1541
|
)
|
|
1358
|
-
|
|
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)
|
|
1359
1545
|
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1360
1546
|
if len(application_names) == 49:
|
|
1361
|
-
logger.info(
|
|
1362
|
-
"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",
|
|
1363
1549
|
article["number"],
|
|
1364
1550
|
)
|
|
1365
1551
|
break
|
|
1552
|
+
# end if application
|
|
1366
1553
|
else:
|
|
1367
|
-
logger.warning(
|
|
1368
|
-
"Article -> %s: Cannot lookup related
|
|
1554
|
+
self.logger.warning(
|
|
1555
|
+
"Article -> %s: Cannot lookup related application name in table -> '%s' with key -> %s",
|
|
1369
1556
|
article["number"],
|
|
1370
1557
|
application_table_name,
|
|
1371
1558
|
application_key,
|
|
1372
1559
|
)
|
|
1373
1560
|
else:
|
|
1374
|
-
logger.
|
|
1375
|
-
"Article -> %s has no
|
|
1561
|
+
self.logger.debug(
|
|
1562
|
+
"Article -> %s has no related applications!",
|
|
1376
1563
|
article["number"],
|
|
1377
1564
|
)
|
|
1378
|
-
|
|
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)
|
|
1379
1568
|
|
|
1380
|
-
application_versions =
|
|
1569
|
+
application_versions: set = set()
|
|
1381
1570
|
application_version_sets = []
|
|
1382
|
-
if article.get("u_application_version"
|
|
1571
|
+
if article.get("u_application_version"):
|
|
1383
1572
|
application_version_keys = article.get("u_application_version").split(",")
|
|
1384
1573
|
for application_version_key in application_version_keys:
|
|
1385
1574
|
# Get the version object from ServiceNow. It includes both,
|
|
@@ -1390,74 +1579,107 @@ class ServiceNow(object):
|
|
|
1390
1579
|
)
|
|
1391
1580
|
if application_version:
|
|
1392
1581
|
application_version_name = self.get_result_value(
|
|
1393
|
-
response=application_version,
|
|
1582
|
+
response=application_version,
|
|
1583
|
+
key="u_version_name",
|
|
1394
1584
|
)
|
|
1395
|
-
logger.debug(
|
|
1396
|
-
"Found related
|
|
1585
|
+
self.logger.debug(
|
|
1586
|
+
"Found related application version -> '%s' in table -> '%s' with key -> '%s'",
|
|
1587
|
+
application_version_name,
|
|
1397
1588
|
SN_TABLE_PRODUCT_LINES,
|
|
1398
1589
|
application_version_key,
|
|
1399
1590
|
)
|
|
1400
1591
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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)
|
|
1404
1595
|
|
|
1596
|
+
# Use the application key to lookup application name
|
|
1597
|
+
# for the version and fill a set
|
|
1405
1598
|
application_key = self.get_result_value(
|
|
1406
|
-
response=application_version,
|
|
1599
|
+
response=application_version,
|
|
1600
|
+
key="u_product_model",
|
|
1407
1601
|
)
|
|
1408
1602
|
|
|
1409
1603
|
if application_key:
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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:
|
|
1416
1612
|
application_key = application_key.get("value")
|
|
1417
1613
|
|
|
1418
1614
|
if application_key:
|
|
1615
|
+
# Retrieve the application with the application key from ServiceNBow:
|
|
1419
1616
|
application = self.get_object(
|
|
1420
1617
|
table_name=SN_TABLE_PRODUCT_LINES,
|
|
1421
1618
|
sys_id=application_key,
|
|
1422
1619
|
)
|
|
1423
|
-
|
|
1424
1620
|
application_name = self.get_result_value(
|
|
1425
|
-
response=application,
|
|
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,
|
|
1426
1647
|
)
|
|
1427
1648
|
|
|
1428
1649
|
if application_name:
|
|
1429
1650
|
application_version_sets.append(
|
|
1430
1651
|
{
|
|
1431
|
-
# "Application": application_name,
|
|
1432
|
-
# "Version": application_version_name,
|
|
1433
1652
|
"u_product_model": application_name,
|
|
1434
1653
|
"u_version_name": application_version_name,
|
|
1435
|
-
}
|
|
1654
|
+
},
|
|
1436
1655
|
)
|
|
1656
|
+
# end if application_key
|
|
1437
1657
|
|
|
1438
1658
|
# Extended ECM can only handle a maxiumum of 50 line items:
|
|
1439
|
-
if len(
|
|
1440
|
-
logger.info(
|
|
1441
|
-
"Reached maximum of 50 multi-value items for related
|
|
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",
|
|
1442
1662
|
article["number"],
|
|
1443
1663
|
)
|
|
1444
1664
|
break
|
|
1665
|
+
# end if application_version
|
|
1445
1666
|
else:
|
|
1446
|
-
logger.warning(
|
|
1447
|
-
"Article -> %s: Cannot lookup related
|
|
1667
|
+
self.logger.warning(
|
|
1668
|
+
"Article -> %s: Cannot lookup related application version in table -> '%s' with key -> '%s'",
|
|
1448
1669
|
article["number"],
|
|
1449
1670
|
SN_TABLE_PRODUCT_VERSIONS,
|
|
1450
1671
|
application_version_key,
|
|
1451
1672
|
)
|
|
1452
1673
|
else:
|
|
1453
|
-
logger.
|
|
1454
|
-
"Article -> %s has no
|
|
1674
|
+
self.logger.debug(
|
|
1675
|
+
"Article -> %s has no related application version!",
|
|
1455
1676
|
article["number"],
|
|
1456
1677
|
)
|
|
1457
|
-
#
|
|
1458
|
-
|
|
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)
|
|
1459
1681
|
|
|
1460
|
-
# This
|
|
1682
|
+
# This list of dictionaries maps the applications and the versions (table-like structure)
|
|
1461
1683
|
article["u_application_version_sets"] = application_version_sets
|
|
1462
1684
|
|
|
1463
1685
|
# Now we add the article to the Pandas Data Frame in the Data class:
|