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