pyxecm 1.5__py3-none-any.whl → 1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pyxecm might be problematic. Click here for more details.
- pyxecm/__init__.py +2 -0
- pyxecm/avts.py +1065 -0
- pyxecm/coreshare.py +467 -571
- pyxecm/customizer/customizer.py +160 -19
- pyxecm/customizer/k8s.py +139 -25
- pyxecm/customizer/m365.py +694 -1498
- pyxecm/customizer/payload.py +2306 -485
- pyxecm/customizer/pht.py +547 -124
- pyxecm/customizer/salesforce.py +378 -443
- pyxecm/customizer/servicenow.py +379 -133
- pyxecm/helper/assoc.py +20 -0
- pyxecm/helper/data.py +237 -33
- pyxecm/helper/xml.py +1 -1
- pyxecm/otawp.py +1810 -0
- pyxecm/otcs.py +3180 -2938
- pyxecm/otds.py +1591 -1875
- pyxecm/otmm.py +131 -11
- {pyxecm-1.5.dist-info → pyxecm-1.6.dist-info}/METADATA +3 -1
- pyxecm-1.6.dist-info/RECORD +32 -0
- {pyxecm-1.5.dist-info → pyxecm-1.6.dist-info}/WHEEL +1 -1
- pyxecm-1.5.dist-info/RECORD +0 -30
- {pyxecm-1.5.dist-info → pyxecm-1.6.dist-info}/LICENSE +0 -0
- {pyxecm-1.5.dist-info → pyxecm-1.6.dist-info}/top_level.txt +0 -0
pyxecm/customizer/servicenow.py
CHANGED
|
@@ -6,8 +6,9 @@ Class: ServiceNow
|
|
|
6
6
|
Methods:
|
|
7
7
|
|
|
8
8
|
__init__ : class initializer
|
|
9
|
-
|
|
10
|
-
|
|
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)
|
|
11
12
|
request_header: Returns the request header for ServiceNow API calls
|
|
12
13
|
parse_request_response: Parse the REST API responses and convert
|
|
13
14
|
them to Python dict in a safe way
|
|
@@ -18,18 +19,21 @@ get_result_value: Check if a defined value (based on a key) is in the ServiceNow
|
|
|
18
19
|
authenticate : Authenticates at ServiceNow API
|
|
19
20
|
get_oauth_token: Returns the OAuth access token.
|
|
20
21
|
|
|
21
|
-
get_data: Get the Data object that holds all processed Knowledge base Articles
|
|
22
22
|
get_object: Get an ServiceNow object based on table name and ID
|
|
23
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
|
|
24
28
|
get_knowledge_base_articles: Get selected / filtered Knowledge Base articles
|
|
25
29
|
make_file_names_unique: Make file names unique if required. The mutable
|
|
26
30
|
list is changed "in-place".
|
|
27
31
|
download_attachments: Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
|
|
28
32
|
load_articles: Main method to load ServiceNow articles in a Data Frame and
|
|
29
33
|
download the attchments.
|
|
34
|
+
load_articles_worker: Worker Method for multi-threading.
|
|
30
35
|
load_article: Process a single KBA: download attachments (if any)
|
|
31
36
|
and add the KBA to the Data Frame.
|
|
32
|
-
load_articles_worker: Worker Method for multi-threading.
|
|
33
37
|
"""
|
|
34
38
|
|
|
35
39
|
__author__ = "Dr. Marc Diefenbruch"
|
|
@@ -60,6 +64,18 @@ REQUEST_TIMEOUT = 60
|
|
|
60
64
|
|
|
61
65
|
KNOWLEDGE_BASE_PATH = "/tmp/attachments"
|
|
62
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
|
+
|
|
63
79
|
|
|
64
80
|
class ServiceNow(object):
|
|
65
81
|
"""Used to retrieve and automate stettings in ServiceNow."""
|
|
@@ -117,10 +133,10 @@ class ServiceNow(object):
|
|
|
117
133
|
servicenow_config["restUrl"] + "table/kb_knowledge"
|
|
118
134
|
)
|
|
119
135
|
servicenow_config["knowledgeBaseUrl"] = (
|
|
120
|
-
servicenow_config["restUrl"] + "table/
|
|
136
|
+
servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
|
|
121
137
|
)
|
|
122
138
|
servicenow_config["attachmentsUrl"] = (
|
|
123
|
-
servicenow_config["restUrl"] + "table/
|
|
139
|
+
servicenow_config["restUrl"] + "table/" + SN_TABLE_ATTACHMENTS
|
|
124
140
|
)
|
|
125
141
|
servicenow_config["attachmentDownloadUrl"] = (
|
|
126
142
|
servicenow_config["restUrl"] + "attachment"
|
|
@@ -141,11 +157,14 @@ class ServiceNow(object):
|
|
|
141
157
|
|
|
142
158
|
def thread_wrapper(self, target, *args, **kwargs):
|
|
143
159
|
"""Function to wrap around threads to catch exceptions during exection"""
|
|
160
|
+
|
|
144
161
|
try:
|
|
145
162
|
target(*args, **kwargs)
|
|
146
163
|
except Exception as e:
|
|
147
164
|
thread_name = threading.current_thread().name
|
|
148
|
-
logger.error(
|
|
165
|
+
logger.error(
|
|
166
|
+
"Thread '%s': failed with exception -> %s", thread_name, str(e)
|
|
167
|
+
)
|
|
149
168
|
logger.error(traceback.format_exc())
|
|
150
169
|
|
|
151
170
|
# end method definition
|
|
@@ -176,7 +195,11 @@ class ServiceNow(object):
|
|
|
176
195
|
Consists of Bearer access token and Content Type
|
|
177
196
|
|
|
178
197
|
Args:
|
|
179
|
-
content_type (str, optional): custom content type for the request
|
|
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
|
|
180
203
|
Return:
|
|
181
204
|
dict: request header values
|
|
182
205
|
"""
|
|
@@ -316,7 +339,14 @@ class ServiceNow(object):
|
|
|
316
339
|
# end method definition
|
|
317
340
|
|
|
318
341
|
def authenticate(self, auth_type: str) -> str | None:
|
|
319
|
-
"""Authenticate at ServiceNow with client ID and client secret or with basic authentication.
|
|
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
|
+
"""
|
|
320
350
|
|
|
321
351
|
self._session.headers.update(self.request_header())
|
|
322
352
|
|
|
@@ -441,11 +471,13 @@ class ServiceNow(object):
|
|
|
441
471
|
summary_sys_id (str): System ID of the article
|
|
442
472
|
|
|
443
473
|
Returns:
|
|
444
|
-
dict | None:
|
|
474
|
+
dict | None: dictionary with the summary
|
|
445
475
|
"""
|
|
446
476
|
|
|
447
477
|
return self.get_object(table_name="kb_knowledge_summary", sys_id=summary_sys_id)
|
|
448
478
|
|
|
479
|
+
# end method definition
|
|
480
|
+
|
|
449
481
|
def get_table(
|
|
450
482
|
self,
|
|
451
483
|
table_name: str,
|
|
@@ -455,11 +487,11 @@ class ServiceNow(object):
|
|
|
455
487
|
offset: int = 0,
|
|
456
488
|
error_string: str = "",
|
|
457
489
|
) -> list | None:
|
|
458
|
-
"""Retrieve a specified ServiceNow
|
|
490
|
+
"""Retrieve a specified ServiceNow table data (row or values).
|
|
459
491
|
|
|
460
492
|
Args:
|
|
461
493
|
table_name (str): Name of the ServiceNow table
|
|
462
|
-
query (str, optional): Query to filter the
|
|
494
|
+
query (str, optional): Query to filter the table rows (e.g. articles).
|
|
463
495
|
fields (list, optional): Just return the fileds in this list.
|
|
464
496
|
Defaults to None which means to deliver
|
|
465
497
|
all fields.
|
|
@@ -516,8 +548,98 @@ class ServiceNow(object):
|
|
|
516
548
|
|
|
517
549
|
return None
|
|
518
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
|
+
|
|
519
641
|
def get_knowledge_bases(self) -> list | None:
|
|
520
|
-
"""Get the configured knowledge bases in
|
|
642
|
+
"""Get the configured knowledge bases in ServiceNow.
|
|
521
643
|
|
|
522
644
|
Returns:
|
|
523
645
|
list | None: list of configured knowledge bases or None in case of an error.
|
|
@@ -579,58 +701,15 @@ class ServiceNow(object):
|
|
|
579
701
|
"""
|
|
580
702
|
|
|
581
703
|
return self.get_table(
|
|
582
|
-
table_name=
|
|
704
|
+
table_name=SN_TABLE_KNOWLEDGE_BASES,
|
|
705
|
+
error_string="Cannot get Knowledge Bases; ",
|
|
583
706
|
)
|
|
584
707
|
|
|
585
708
|
# end method definition
|
|
586
709
|
|
|
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
710
|
def get_knowledge_base_articles(
|
|
633
711
|
self,
|
|
712
|
+
table_name: str = SN_TABLE_KNOWLEDGE_BASE_ARTICLES,
|
|
634
713
|
query: str = "",
|
|
635
714
|
fields: list | None = None,
|
|
636
715
|
limit: int | None = 10,
|
|
@@ -756,7 +835,7 @@ class ServiceNow(object):
|
|
|
756
835
|
"""
|
|
757
836
|
|
|
758
837
|
return self.get_table(
|
|
759
|
-
table_name=
|
|
838
|
+
table_name=table_name, # derived from table kb_knowledge
|
|
760
839
|
query=query,
|
|
761
840
|
fields=fields,
|
|
762
841
|
limit=limit,
|
|
@@ -802,19 +881,14 @@ class ServiceNow(object):
|
|
|
802
881
|
|
|
803
882
|
# end method definition
|
|
804
883
|
|
|
805
|
-
def
|
|
806
|
-
|
|
807
|
-
article: dict,
|
|
808
|
-
skip_existing: bool = True,
|
|
809
|
-
) -> bool:
|
|
810
|
-
"""Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
|
|
884
|
+
def get_article_attachments(self, article: dict) -> list | None:
|
|
885
|
+
"""Get a list of attachments for an article
|
|
811
886
|
|
|
812
887
|
Args:
|
|
813
|
-
article (dict):
|
|
814
|
-
skip_existing (bool, optional): skip download if file has been downloaded before
|
|
888
|
+
article (dict): Article information
|
|
815
889
|
|
|
816
890
|
Returns:
|
|
817
|
-
|
|
891
|
+
list | None: list of attachments
|
|
818
892
|
"""
|
|
819
893
|
|
|
820
894
|
article_sys_id = article["sys_id"]
|
|
@@ -836,76 +910,127 @@ class ServiceNow(object):
|
|
|
836
910
|
attachments = data.get("result", [])
|
|
837
911
|
if not attachments:
|
|
838
912
|
logger.debug(
|
|
839
|
-
"Knowledge base article -> %s does not have attachments
|
|
913
|
+
"Knowledge base article -> %s does not have attachments!",
|
|
840
914
|
article_number,
|
|
841
915
|
)
|
|
842
|
-
|
|
843
|
-
return False
|
|
916
|
+
return []
|
|
844
917
|
else:
|
|
845
918
|
logger.info(
|
|
846
|
-
"Knowledge base article -> %s has %s attachments
|
|
919
|
+
"Knowledge base article -> %s has %s attachments.",
|
|
847
920
|
article_number,
|
|
848
921
|
len(attachments),
|
|
849
922
|
)
|
|
850
|
-
|
|
923
|
+
return attachments
|
|
851
924
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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))
|
|
855
931
|
|
|
856
|
-
|
|
932
|
+
return None
|
|
857
933
|
|
|
858
|
-
|
|
859
|
-
article["download_dir"] = base_dir
|
|
934
|
+
# end method definition
|
|
860
935
|
|
|
861
|
-
|
|
862
|
-
|
|
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.
|
|
863
942
|
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
866
946
|
|
|
867
|
-
|
|
868
|
-
|
|
947
|
+
Returns:
|
|
948
|
+
bool: True = success, False = failure
|
|
949
|
+
"""
|
|
869
950
|
|
|
870
|
-
|
|
871
|
-
|
|
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...
|
|
872
995
|
article["download_files"].append(attachment["file_name"])
|
|
873
996
|
article["download_files_ids"].append(attachment["sys_id"])
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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,
|
|
885
1009
|
)
|
|
1010
|
+
|
|
886
1011
|
attachment_response = self._session.get(
|
|
887
1012
|
attachment_download_url, stream=True
|
|
888
1013
|
)
|
|
889
1014
|
attachment_response.raise_for_status()
|
|
890
1015
|
|
|
891
|
-
logger.info(
|
|
892
|
-
"Downloading attachment file -> '%s' for article -> %s from ServiceNow...",
|
|
893
|
-
file_path,
|
|
894
|
-
article_number,
|
|
895
|
-
)
|
|
896
1016
|
with open(file_path, "wb") as file:
|
|
897
1017
|
for chunk in attachment_response.iter_content(chunk_size=8192):
|
|
898
1018
|
file.write(chunk)
|
|
899
1019
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
logger.error("Request error occurred -> %s!", str(req_err))
|
|
905
|
-
except Exception as err:
|
|
906
|
-
logger.error("An error occurred -> %s!", str(err))
|
|
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"])
|
|
907
1024
|
|
|
908
|
-
|
|
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
|
|
909
1034
|
|
|
910
1035
|
# end method definition
|
|
911
1036
|
|
|
@@ -921,10 +1046,18 @@ class ServiceNow(object):
|
|
|
921
1046
|
"""
|
|
922
1047
|
|
|
923
1048
|
total_count = self.get_table_count(table_name=table_name, query=query)
|
|
1049
|
+
|
|
924
1050
|
logger.info(
|
|
925
1051
|
"Total number of Knowledge Base Articles (KBA) -> %s", str(total_count)
|
|
926
1052
|
)
|
|
927
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
|
+
|
|
928
1061
|
number = self._thread_number
|
|
929
1062
|
|
|
930
1063
|
if total_count >= number:
|
|
@@ -936,8 +1069,9 @@ class ServiceNow(object):
|
|
|
936
1069
|
number = 1
|
|
937
1070
|
|
|
938
1071
|
logger.info(
|
|
939
|
-
"Processing -> %s Knowledge Base Articles (KBA), thread number -> %s, partition size -> %s",
|
|
1072
|
+
"Processing -> %s Knowledge Base Articles (KBA), table name -> '%s', thread number -> %s, partition size -> %s",
|
|
940
1073
|
str(total_count),
|
|
1074
|
+
table_name,
|
|
941
1075
|
number,
|
|
942
1076
|
partition_size,
|
|
943
1077
|
)
|
|
@@ -952,6 +1086,7 @@ class ServiceNow(object):
|
|
|
952
1086
|
target=self.thread_wrapper,
|
|
953
1087
|
args=(
|
|
954
1088
|
self.load_articles_worker,
|
|
1089
|
+
table_name,
|
|
955
1090
|
query,
|
|
956
1091
|
current_partition_size,
|
|
957
1092
|
current_offset,
|
|
@@ -969,7 +1104,7 @@ class ServiceNow(object):
|
|
|
969
1104
|
# end method definition
|
|
970
1105
|
|
|
971
1106
|
def load_articles_worker(
|
|
972
|
-
self, query: str, partition_size: int, partition_offset: int
|
|
1107
|
+
self, table_name: str, query: str, partition_size: int, partition_offset: int
|
|
973
1108
|
) -> None:
|
|
974
1109
|
"""Worker Method for multi-threading.
|
|
975
1110
|
|
|
@@ -980,9 +1115,10 @@ class ServiceNow(object):
|
|
|
980
1115
|
"""
|
|
981
1116
|
|
|
982
1117
|
logger.info(
|
|
983
|
-
"
|
|
1118
|
+
"Start processing KBAs in range from -> %s to -> %s from table -> '%s'...",
|
|
984
1119
|
partition_offset,
|
|
985
1120
|
partition_offset + partition_size,
|
|
1121
|
+
table_name,
|
|
986
1122
|
)
|
|
987
1123
|
|
|
988
1124
|
# We cannot retrieve all KBAs in one go if the partition size is too big (> 100)
|
|
@@ -992,8 +1128,8 @@ class ServiceNow(object):
|
|
|
992
1128
|
limit = 100 if partition_size > 100 else partition_size
|
|
993
1129
|
|
|
994
1130
|
for offset in range(partition_offset, partition_offset + partition_size, limit):
|
|
995
|
-
articles = self.
|
|
996
|
-
query=query, limit=limit, offset=offset
|
|
1131
|
+
articles = self.get_table(
|
|
1132
|
+
table_name=table_name, query=query, limit=limit, offset=offset
|
|
997
1133
|
)
|
|
998
1134
|
logger.info(
|
|
999
1135
|
"Retrieved a list of %s KBAs starting at offset -> %s to process.",
|
|
@@ -1002,17 +1138,43 @@ class ServiceNow(object):
|
|
|
1002
1138
|
)
|
|
1003
1139
|
for article in articles:
|
|
1004
1140
|
logger.info("Processing KBA -> %s...", article["number"])
|
|
1141
|
+
article["source_table"] = table_name
|
|
1005
1142
|
self.load_article(article)
|
|
1006
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
|
+
|
|
1007
1151
|
# end method definition
|
|
1008
1152
|
|
|
1009
1153
|
def load_article(self, article: dict, skip_existing_downloads: bool = True):
|
|
1010
|
-
"""Process a single KBA: download attachments (if any)
|
|
1011
|
-
|
|
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.
|
|
1012
1157
|
|
|
1013
1158
|
Args:
|
|
1014
1159
|
article (dict): Dictionary inclusing all fields of
|
|
1015
|
-
a single KBA.
|
|
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
|
+
|
|
1016
1178
|
"""
|
|
1017
1179
|
|
|
1018
1180
|
_ = self.download_attachments(
|
|
@@ -1023,9 +1185,9 @@ class ServiceNow(object):
|
|
|
1023
1185
|
# Add additional columns from related ServiceNow tables:
|
|
1024
1186
|
#
|
|
1025
1187
|
|
|
1026
|
-
if article
|
|
1188
|
+
if "kb_category" in article and article["kb_category"]:
|
|
1027
1189
|
category_key = article.get("kb_category")["value"]
|
|
1028
|
-
category_table_name =
|
|
1190
|
+
category_table_name = SN_TABLE_CATEGORIES
|
|
1029
1191
|
category = self.get_object(
|
|
1030
1192
|
table_name=category_table_name, sys_id=category_key
|
|
1031
1193
|
)
|
|
@@ -1039,11 +1201,13 @@ class ServiceNow(object):
|
|
|
1039
1201
|
)
|
|
1040
1202
|
article["kb_category_name"] = ""
|
|
1041
1203
|
else:
|
|
1042
|
-
logger.
|
|
1204
|
+
logger.warning(
|
|
1205
|
+
"Article -> %s has no value for category!", article["number"]
|
|
1206
|
+
)
|
|
1043
1207
|
article["kb_category_name"] = ""
|
|
1044
1208
|
|
|
1045
1209
|
knowledge_base_key = article.get("kb_knowledge_base")["value"]
|
|
1046
|
-
knowledge_base_table_name =
|
|
1210
|
+
knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
|
|
1047
1211
|
knowledge_base = self.get_object(
|
|
1048
1212
|
table_name=knowledge_base_table_name, sys_id=knowledge_base_key
|
|
1049
1213
|
)
|
|
@@ -1059,12 +1223,11 @@ class ServiceNow(object):
|
|
|
1059
1223
|
article["kb_knowledge_base_name"] = ""
|
|
1060
1224
|
|
|
1061
1225
|
related_product_names = []
|
|
1062
|
-
if article.get("related_products"):
|
|
1226
|
+
if article.get("related_products", None):
|
|
1063
1227
|
related_product_keys = article.get("related_products").split(",")
|
|
1064
|
-
related_product_table = "cmdb_model"
|
|
1065
1228
|
for related_product_key in related_product_keys:
|
|
1066
1229
|
related_product = self.get_object(
|
|
1067
|
-
table_name=
|
|
1230
|
+
table_name=SN_TABLE_RELATED_PRODUCTS, sys_id=related_product_key
|
|
1068
1231
|
)
|
|
1069
1232
|
if related_product:
|
|
1070
1233
|
related_product_name = self.get_result_value(
|
|
@@ -1087,7 +1250,7 @@ class ServiceNow(object):
|
|
|
1087
1250
|
logger.warning(
|
|
1088
1251
|
"Article -> %s: Cannot lookup related Product name in table -> '%s' with ID -> %s",
|
|
1089
1252
|
article["number"],
|
|
1090
|
-
|
|
1253
|
+
SN_TABLE_RELATED_PRODUCTS,
|
|
1091
1254
|
related_product_key,
|
|
1092
1255
|
)
|
|
1093
1256
|
else:
|
|
@@ -1100,7 +1263,7 @@ class ServiceNow(object):
|
|
|
1100
1263
|
product_line_names = []
|
|
1101
1264
|
if article.get("u_product_line", None):
|
|
1102
1265
|
product_line_keys = article.get("u_product_line").split(",")
|
|
1103
|
-
product_line_table =
|
|
1266
|
+
product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1104
1267
|
for product_line_key in product_line_keys:
|
|
1105
1268
|
product_line = self.get_object(
|
|
1106
1269
|
table_name=product_line_table, sys_id=product_line_key
|
|
@@ -1123,7 +1286,7 @@ class ServiceNow(object):
|
|
|
1123
1286
|
)
|
|
1124
1287
|
break
|
|
1125
1288
|
else:
|
|
1126
|
-
logger.
|
|
1289
|
+
logger.warning(
|
|
1127
1290
|
"Article -> %s: Cannot lookup related Product Line name in table -> '%s' with ID -> %s",
|
|
1128
1291
|
article["number"],
|
|
1129
1292
|
product_line_table,
|
|
@@ -1139,7 +1302,7 @@ class ServiceNow(object):
|
|
|
1139
1302
|
sub_product_line_names = []
|
|
1140
1303
|
if article.get("u_sub_product_line", None):
|
|
1141
1304
|
sub_product_line_keys = article.get("u_sub_product_line").split(",")
|
|
1142
|
-
sub_product_line_table =
|
|
1305
|
+
sub_product_line_table = SN_TABLE_PRODUCT_LINES
|
|
1143
1306
|
for sub_product_line_key in sub_product_line_keys:
|
|
1144
1307
|
sub_product_line = self.get_object(
|
|
1145
1308
|
table_name=sub_product_line_table, sys_id=sub_product_line_key
|
|
@@ -1162,7 +1325,7 @@ class ServiceNow(object):
|
|
|
1162
1325
|
)
|
|
1163
1326
|
break
|
|
1164
1327
|
else:
|
|
1165
|
-
logger.
|
|
1328
|
+
logger.warning(
|
|
1166
1329
|
"Article -> %s: Cannot lookup related Sub Product Line name in table -> '%s' with ID -> %s",
|
|
1167
1330
|
article["number"],
|
|
1168
1331
|
sub_product_line_table,
|
|
@@ -1178,7 +1341,7 @@ class ServiceNow(object):
|
|
|
1178
1341
|
application_names = []
|
|
1179
1342
|
if article.get("u_application", None):
|
|
1180
1343
|
application_keys = article.get("u_application").split(",")
|
|
1181
|
-
application_table_name =
|
|
1344
|
+
application_table_name = SN_TABLE_PRODUCT_LINES
|
|
1182
1345
|
for application_key in application_keys:
|
|
1183
1346
|
application = self.get_object(
|
|
1184
1347
|
table_name=application_table_name, sys_id=application_key
|
|
@@ -1214,6 +1377,89 @@ class ServiceNow(object):
|
|
|
1214
1377
|
)
|
|
1215
1378
|
article["u_application_names"] = application_names
|
|
1216
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
|
+
|
|
1217
1463
|
# Now we add the article to the Pandas Data Frame in the Data class:
|
|
1218
1464
|
with self._data.lock():
|
|
1219
1465
|
self._data.append(article)
|