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.

@@ -6,8 +6,9 @@ Class: ServiceNow
6
6
  Methods:
7
7
 
8
8
  __init__ : class initializer
9
- config : Returns config data set
10
- credentials: Returns the token data
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/kb_knowledge_base"
136
+ servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
121
137
  )
122
138
  servicenow_config["attachmentsUrl"] = (
123
- servicenow_config["restUrl"] + "table/sys_attachment"
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("Thread %s: failed with exception %s", thread_name, e)
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: _description_
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 column.
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 the articles.
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 Service Now.
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="kb_knowledge_base", error_string="Cannot get Knowledge Bases; "
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="u_kb_template_technical_article_public", # derived from table kb_knowledge
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 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.
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): dictionary holding the Service Now article data
814
- skip_existing (bool, optional): skip download if file has been downloaded before
888
+ article (dict): Article information
815
889
 
816
890
  Returns:
817
- bool: True = success, False = failure
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 to download!",
913
+ "Knowledge base article -> %s does not have attachments!",
840
914
  article_number,
841
915
  )
842
- article["has_attachments"] = False
843
- return False
916
+ return []
844
917
  else:
845
918
  logger.info(
846
- "Knowledge base article -> %s has %s attachments to download...",
919
+ "Knowledge base article -> %s has %s attachments.",
847
920
  article_number,
848
921
  len(attachments),
849
922
  )
850
- article["has_attachments"] = True
923
+ return attachments
851
924
 
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)
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
- base_dir = os.path.join(self._download_dir, article_number)
932
+ return None
857
933
 
858
- # save download dir for later use in bulkDocument processing...
859
- article["download_dir"] = base_dir
934
+ # end method definition
860
935
 
861
- article["download_files"] = []
862
- article["download_files_ids"] = []
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
- if not os.path.exists(base_dir):
865
- os.makedirs(base_dir)
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
- for attachment in attachments:
868
- file_path = os.path.join(base_dir, attachment["file_name"])
947
+ Returns:
948
+ bool: True = success, False = failure
949
+ """
869
950
 
870
- # we build a list of filenames and ids.
871
- # the ids we want to use as nicknames later on
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
- 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"
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
- 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))
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
- return False
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
- "Processing KBAs in range from -> %s to -> %s...",
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.get_knowledge_base_articles(
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
- and add the KBA to the Data Frame.
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.get("kb_category"):
1188
+ if "kb_category" in article and article["kb_category"]:
1027
1189
  category_key = article.get("kb_category")["value"]
1028
- category_table_name = "kb_category"
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.error("Article -> %s has no value for category!", article["number"])
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 = "kb_knowledge_base"
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=related_product_table, sys_id=related_product_key
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
- related_product_table,
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 = "u_ot_product_model"
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.error(
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 = "u_ot_product_model"
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.error(
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 = "u_ot_product_model"
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)