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.

@@ -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