pyxecm 3.0.1__py3-none-any.whl → 3.1.1__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.

Files changed (52) hide show
  1. pyxecm/avts.py +4 -4
  2. pyxecm/coreshare.py +14 -15
  3. pyxecm/helper/data.py +2 -1
  4. pyxecm/helper/web.py +11 -11
  5. pyxecm/helper/xml.py +41 -10
  6. pyxecm/otac.py +1 -1
  7. pyxecm/otawp.py +19 -19
  8. pyxecm/otca.py +878 -70
  9. pyxecm/otcs.py +1716 -349
  10. pyxecm/otds.py +332 -153
  11. pyxecm/otkd.py +4 -4
  12. pyxecm/otmm.py +1 -1
  13. pyxecm/otpd.py +246 -30
  14. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/METADATA +2 -1
  15. pyxecm-3.1.1.dist-info/RECORD +82 -0
  16. pyxecm_api/app.py +45 -35
  17. pyxecm_api/auth/functions.py +2 -2
  18. pyxecm_api/auth/router.py +2 -3
  19. pyxecm_api/common/functions.py +67 -12
  20. pyxecm_api/settings.py +0 -8
  21. pyxecm_api/terminal/router.py +1 -1
  22. pyxecm_api/v1_csai/router.py +33 -18
  23. pyxecm_customizer/browser_automation.py +161 -79
  24. pyxecm_customizer/customizer.py +43 -25
  25. pyxecm_customizer/guidewire.py +422 -8
  26. pyxecm_customizer/k8s.py +23 -27
  27. pyxecm_customizer/knowledge_graph.py +498 -20
  28. pyxecm_customizer/m365.py +45 -44
  29. pyxecm_customizer/payload.py +1723 -1188
  30. pyxecm_customizer/payload_list.py +3 -0
  31. pyxecm_customizer/salesforce.py +122 -79
  32. pyxecm_customizer/servicenow.py +27 -7
  33. pyxecm_customizer/settings.py +3 -1
  34. pyxecm_customizer/successfactors.py +2 -2
  35. pyxecm_customizer/translate.py +1 -1
  36. pyxecm-3.0.1.dist-info/RECORD +0 -96
  37. pyxecm_api/agents/__init__.py +0 -7
  38. pyxecm_api/agents/app.py +0 -13
  39. pyxecm_api/agents/functions.py +0 -119
  40. pyxecm_api/agents/models.py +0 -10
  41. pyxecm_api/agents/otcm_knowledgegraph/__init__.py +0 -1
  42. pyxecm_api/agents/otcm_knowledgegraph/functions.py +0 -85
  43. pyxecm_api/agents/otcm_knowledgegraph/models.py +0 -61
  44. pyxecm_api/agents/otcm_knowledgegraph/router.py +0 -74
  45. pyxecm_api/agents/otcm_user_agent/__init__.py +0 -1
  46. pyxecm_api/agents/otcm_user_agent/models.py +0 -20
  47. pyxecm_api/agents/otcm_user_agent/router.py +0 -65
  48. pyxecm_api/agents/otcm_workspace_agent/__init__.py +0 -1
  49. pyxecm_api/agents/otcm_workspace_agent/models.py +0 -40
  50. pyxecm_api/agents/otcm_workspace_agent/router.py +0 -200
  51. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/WHEEL +0 -0
  52. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/entry_points.txt +0 -0
pyxecm/otcs.py CHANGED
@@ -14,6 +14,8 @@ __maintainer__ = "Dr. Marc Diefenbruch"
14
14
  __email__ = "mdiefenb@opentext.com"
15
15
 
16
16
  import asyncio
17
+ import hashlib
18
+ import html
17
19
  import json
18
20
  import logging
19
21
  import mimetypes
@@ -26,6 +28,7 @@ import tempfile
26
28
  import threading
27
29
  import time
28
30
  import urllib.parse
31
+ import xml.etree.ElementTree as ET
29
32
  import zipfile
30
33
  from concurrent.futures import ThreadPoolExecutor
31
34
  from datetime import UTC, datetime
@@ -73,8 +76,8 @@ REQUEST_DOWNLOAD_HEADERS = {
73
76
  "Content-Type": "application/json",
74
77
  }
75
78
 
76
- REQUEST_TIMEOUT = 60
77
- REQUEST_RETRY_DELAY = 30
79
+ REQUEST_TIMEOUT = 60.0
80
+ REQUEST_RETRY_DELAY = 30.0
78
81
  REQUEST_MAX_RETRIES = 4
79
82
 
80
83
  default_logger = logging.getLogger(MODULE_NAME)
@@ -116,6 +119,7 @@ class OTCS:
116
119
 
117
120
  ITEM_TYPE_BUSINESS_WORKSPACE = 848
118
121
  ITEM_TYPE_CATEGORY = 131
122
+ ITEM_TYPE_CATEGORY_FOLDER = 132
119
123
  ITEM_TYPE_CHANNEL = 207
120
124
  ITEM_TYPE_CLASSIFICATION_TREE = 196
121
125
  ITEM_TYPE_CLASSIFICATION = 199
@@ -157,9 +161,11 @@ class OTCS:
157
161
  ITEM_TYPE_BUSINESS_WORKSPACE,
158
162
  ITEM_TYPE_COMPOUND_DOCUMENT,
159
163
  ITEM_TYPE_CLASSIFICATION,
164
+ ITEM_TYPE_CATEGORY_FOLDER,
160
165
  VOLUME_TYPE_ENTERPRISE_WORKSPACE,
161
166
  VOLUME_TYPE_CLASSIFICATION_VOLUME,
162
167
  VOLUME_TYPE_CONTENT_SERVER_DOCUMENT_TEMPLATES,
168
+ VOLUME_TYPE_CATEGORIES_VOLUME,
163
169
  ]
164
170
 
165
171
  PERMISSION_TYPES = [
@@ -187,6 +193,7 @@ class OTCS:
187
193
  _config: dict
188
194
  _otcs_ticket = None
189
195
  _otds_ticket = None
196
+ _otds_token = None
190
197
  _data: Data = None
191
198
  _thread_number = 3
192
199
  _download_dir = ""
@@ -323,6 +330,7 @@ class OTCS:
323
330
  resource_id: str = "",
324
331
  default_license: str = "X3",
325
332
  otds_ticket: str | None = None,
333
+ otds_token: str | None = None,
326
334
  base_path: str = "/cs/cs",
327
335
  support_path: str = "/cssupport",
328
336
  thread_number: int = 3,
@@ -357,6 +365,8 @@ class OTCS:
357
365
  The name of the default user license. Default is "X3".
358
366
  otds_ticket (str, optional):
359
367
  The authentication ticket of OTDS.
368
+ otds_token (str, optional):
369
+ The authentication token of OTDS.
360
370
  base_path (str, optional):
361
371
  The base path segment of the Content Server URL.
362
372
  This typically is /cs/cs on a Linux deployment or /cs/cs.exe
@@ -392,52 +402,16 @@ class OTCS:
392
402
  # Initialize otcs_config as an empty dictionary
393
403
  otcs_config = {}
394
404
 
395
- if hostname:
396
- otcs_config["hostname"] = hostname
397
- else:
398
- otcs_config["hostname"] = "otcs-admin-0"
399
-
400
- if protocol:
401
- otcs_config["protocol"] = protocol
402
- else:
403
- otcs_config["protocol"] = "http"
404
-
405
- if port:
406
- otcs_config["port"] = port
407
- else:
408
- otcs_config["port"] = 8080
409
-
405
+ otcs_config["hostname"] = hostname or "otcs-admin-0"
406
+ otcs_config["protocol"] = protocol or "http"
407
+ otcs_config["port"] = port or 8080
410
408
  otcs_config["publicUrl"] = public_url
411
-
412
- if username:
413
- otcs_config["username"] = username
414
- else:
415
- otcs_config["username"] = "admin"
416
-
417
- if password:
418
- otcs_config["password"] = password
419
- else:
420
- otcs_config["password"] = ""
421
-
422
- if user_partition:
423
- otcs_config["partition"] = user_partition
424
- else:
425
- otcs_config["partition"] = ""
426
-
427
- if resource_name:
428
- otcs_config["resource"] = resource_name
429
- else:
430
- otcs_config["resource"] = ""
431
-
432
- if resource_id:
433
- otcs_config["resourceId"] = resource_id
434
- else:
435
- otcs_config["resourceId"] = None
436
-
437
- if default_license:
438
- otcs_config["license"] = default_license
439
- else:
440
- otcs_config["license"] = ""
409
+ otcs_config["username"] = username or "admin"
410
+ otcs_config["password"] = password or ""
411
+ otcs_config["partition"] = user_partition or ""
412
+ otcs_config["resource"] = resource_name or ""
413
+ otcs_config["resourceId"] = resource_id or None
414
+ otcs_config["license"] = default_license or ""
441
415
 
442
416
  otcs_config["femeUri"] = feme_uri
443
417
 
@@ -500,7 +474,10 @@ class OTCS:
500
474
  otcs_config["holdsUrl"] = otcs_rest_url + "/v1/holds"
501
475
  otcs_config["holdsUrlv2"] = otcs_rest_url + "/v2/holds"
502
476
  otcs_config["validationUrl"] = otcs_rest_url + "/v1/validation/nodes/names"
503
- otcs_config["aiUrl"] = otcs_rest_url + "/v2/ai/nodes"
477
+ otcs_config["aiUrl"] = otcs_rest_url + "/v2/ai"
478
+ otcs_config["aiNodesUrl"] = otcs_config["aiUrl"] + "/nodes"
479
+ otcs_config["aiChatUrl"] = otcs_config["aiUrl"] + "/chat"
480
+ otcs_config["aiContextUrl"] = otcs_config["aiUrl"] + "/context"
504
481
  otcs_config["recycleBinUrl"] = otcs_rest_url + "/v2/volumes/recyclebin"
505
482
  otcs_config["processUrl"] = otcs_rest_url + "/v2/processes"
506
483
  otcs_config["workflowUrl"] = otcs_rest_url + "/v2/workflows"
@@ -510,9 +487,11 @@ class OTCS:
510
487
  otcs_config["nodesFormUrl"] = otcs_rest_url + "/v1/forms/nodes"
511
488
  otcs_config["draftProcessFormUrl"] = otcs_rest_url + "/v1/forms/draftprocesses"
512
489
  otcs_config["processTaskUrl"] = otcs_rest_url + "/v1/forms/processes/tasks/update"
490
+ otcs_config["docGenUrl"] = otcs_url + "?func=xecmpfdocgen"
513
491
 
514
492
  self._config = otcs_config
515
493
  self._otds_ticket = otds_ticket
494
+ self._otds_token = otds_token
516
495
  self._data = Data(logger=self.logger)
517
496
  self._thread_number = thread_number
518
497
  self._download_dir = download_dir
@@ -521,6 +500,7 @@ class OTCS:
521
500
  self._use_numeric_category_identifier = use_numeric_category_identifier
522
501
  self._executor = ThreadPoolExecutor(max_workers=thread_number)
523
502
  self._workspace_type_lookup = {}
503
+ self._workspace_type_names = []
524
504
 
525
505
  # end method definition
526
506
 
@@ -567,6 +547,34 @@ class OTCS:
567
547
 
568
548
  # end method definition
569
549
 
550
+ def otcs_ticket_hashed(self) -> str | None:
551
+ """Return the hashed OTCS ticket.
552
+
553
+ Returns:
554
+ str | None:
555
+ The hashed OTCS ticket (which may be None).
556
+
557
+ """
558
+
559
+ if not self._otcs_ticket:
560
+ return None
561
+
562
+ # Encode the input string before hashing
563
+ encoded_string = self._otcs_ticket.encode("utf-8")
564
+
565
+ # Create a new SHA-512 hash object
566
+ sha512 = hashlib.sha512()
567
+
568
+ # Update the hash object with the input string
569
+ sha512.update(encoded_string)
570
+
571
+ # Get the hexadecimal representation of the hash
572
+ hashed_output = sha512.hexdigest()
573
+
574
+ return hashed_output
575
+
576
+ # end method definition
577
+
570
578
  def set_otcs_ticket(self, ticket: str) -> None:
571
579
  """Set the OTCS ticket.
572
580
 
@@ -593,6 +601,19 @@ class OTCS:
593
601
 
594
602
  # end method definition
595
603
 
604
+ def set_otds_token(self, token: str) -> None:
605
+ """Set the OTDS token.
606
+
607
+ Args:
608
+ token (str):
609
+ The new OTDS token.
610
+
611
+ """
612
+
613
+ self._otds_token = token
614
+
615
+ # end method definition
616
+
596
617
  def credentials(self) -> dict:
597
618
  """Get credentials (username + password).
598
619
 
@@ -903,7 +924,7 @@ class OTCS:
903
924
  data: dict | None = None,
904
925
  json_data: dict | None = None,
905
926
  files: dict | None = None,
906
- timeout: int | None = REQUEST_TIMEOUT,
927
+ timeout: float | None = REQUEST_TIMEOUT,
907
928
  show_error: bool = True,
908
929
  show_warning: bool = False,
909
930
  warning_message: str = "",
@@ -931,7 +952,7 @@ class OTCS:
931
952
  Dictionary of {"name": file-tuple} for multipart encoding upload.
932
953
  The file-tuple can be a 2-tuple ("filename", fileobj) or a 3-tuple
933
954
  ("filename", fileobj, "content_type").
934
- timeout (int | None, optional):
955
+ timeout (float | None, optional):
935
956
  Timeout for the request in seconds. Defaults to REQUEST_TIMEOUT.
936
957
  show_error (bool, optional):
937
958
  Whether or not an error should be logged in case of a failed REST call.
@@ -998,7 +1019,12 @@ class OTCS:
998
1019
  if success_message:
999
1020
  self.logger.info(success_message)
1000
1021
  if parse_request_response and not stream:
1001
- return self.parse_request_response(response_object=response)
1022
+ # There are cases where OTCS returns response.ok (200) but
1023
+ # because of restart or scaling of pods the response text is not
1024
+ # valid JSON. So parse_request_response() may raise an ConnectionError exception that
1025
+ # is handled in the exception block below (with waiting for readiness and retry logic)
1026
+ parsed_response = self.parse_request_response(response_object=response)
1027
+ return parsed_response
1002
1028
  else:
1003
1029
  return response
1004
1030
  # Check if Session has expired - then re-authenticate and try once more
@@ -1103,14 +1129,20 @@ class OTCS:
1103
1129
  else:
1104
1130
  return None
1105
1131
  # end except Timeout
1106
- except requests.exceptions.ConnectionError:
1132
+ except requests.exceptions.ConnectionError as connection_error:
1107
1133
  if retries <= max_retries:
1108
1134
  self.logger.warning(
1109
- "Connection error. Retrying in %s seconds...",
1110
- str(REQUEST_RETRY_DELAY),
1135
+ "Cannot connect to OTCS at -> %s; error -> %s! Retrying in %d seconds... %d/%d",
1136
+ url,
1137
+ str(connection_error),
1138
+ REQUEST_RETRY_DELAY,
1139
+ retries,
1140
+ max_retries,
1111
1141
  )
1112
1142
  retries += 1
1113
1143
 
1144
+ # The connection error could have been caused by a restart of the OTCS pod or services.
1145
+ # So we better check if OTCS is ready to receive requests again before retrying:
1114
1146
  while not self.is_ready():
1115
1147
  self.logger.warning(
1116
1148
  "Content Server is not ready to receive requests. Waiting for state change in %d seconds...",
@@ -1120,8 +1152,9 @@ class OTCS:
1120
1152
 
1121
1153
  else:
1122
1154
  self.logger.error(
1123
- "%s; connection error",
1155
+ "%s; connection error -> %s",
1124
1156
  failure_message,
1157
+ str(connection_error),
1125
1158
  )
1126
1159
  if retry_forever:
1127
1160
  # If it fails after REQUEST_MAX_RETRIES retries
@@ -1160,13 +1193,17 @@ class OTCS:
1160
1193
  The response object delivered by the request call.
1161
1194
  additional_error_message (str):
1162
1195
  Custom error message to include in logs.
1163
- show_error (bool):
1164
- If True, logs an error. If False, logs a warning.
1196
+ show_error (bool, optional):
1197
+ If True, logs an error / raises an exception. If False, logs a warning.
1165
1198
 
1166
1199
  Returns:
1167
1200
  dict | None:
1168
1201
  Parsed response as a dictionary, or None in case of an error.
1169
1202
 
1203
+ Raises:
1204
+ requests.exceptions.ConnectionError:
1205
+ If the response cannot be decoded as JSON.
1206
+
1170
1207
  """
1171
1208
 
1172
1209
  if not response_object:
@@ -1191,12 +1228,13 @@ class OTCS:
1191
1228
  exception,
1192
1229
  )
1193
1230
  if show_error:
1194
- self.logger.error(message)
1195
- else:
1196
- self.logger.debug(message)
1231
+ # Raise ConnectionError instead of returning None
1232
+ raise requests.exceptions.ConnectionError(message) from exception
1233
+ self.logger.warning(message)
1197
1234
  return None
1198
- else:
1199
- return dict_object
1235
+ # end try-except block
1236
+
1237
+ return dict_object
1200
1238
 
1201
1239
  # end method definition
1202
1240
 
@@ -1226,9 +1264,7 @@ class OTCS:
1226
1264
 
1227
1265
  """
1228
1266
 
1229
- if not response:
1230
- return None
1231
- if "results" not in response:
1267
+ if not response or "results" not in response:
1232
1268
  return None
1233
1269
 
1234
1270
  results = response["results"]
@@ -1408,7 +1444,7 @@ class OTCS:
1408
1444
 
1409
1445
  def get_result_value(
1410
1446
  self,
1411
- response: dict,
1447
+ response: dict | None,
1412
1448
  key: str,
1413
1449
  index: int = 0,
1414
1450
  property_name: str = "properties",
@@ -1421,8 +1457,8 @@ class OTCS:
1421
1457
  developer.opentext.com.
1422
1458
 
1423
1459
  Args:
1424
- response (dict):
1425
- REST API response object.
1460
+ response (dict | None):
1461
+ REST API response object. None is also handled.
1426
1462
  key (str):
1427
1463
  Key to find (e.g., "id", "name").
1428
1464
  index (int, optional):
@@ -1435,7 +1471,7 @@ class OTCS:
1435
1471
  Whether an error or just a warning should be logged.
1436
1472
 
1437
1473
  Returns:
1438
- str:
1474
+ str | None:
1439
1475
  Value of the item with the given key, or None if no value is found.
1440
1476
 
1441
1477
  """
@@ -1521,16 +1557,22 @@ class OTCS:
1521
1557
  data = results[index]["data"]
1522
1558
  if isinstance(data, dict):
1523
1559
  # data is a dict - we don't need index value:
1524
- properties = data[property_name]
1560
+ properties = data.get(property_name)
1525
1561
  elif isinstance(data, list):
1526
1562
  # data is a list - this has typically just one item, so we use 0 as index
1527
- properties = data[0][property_name]
1563
+ properties = data[0].get(property_name)
1528
1564
  else:
1529
1565
  self.logger.error(
1530
1566
  "Data needs to be a list or dict but it is -> %s",
1531
1567
  str(type(data)),
1532
1568
  )
1533
1569
  return None
1570
+ if not properties:
1571
+ self.logger.error(
1572
+ "No properties found in data -> %s",
1573
+ str(data),
1574
+ )
1575
+ return None
1534
1576
  if key not in properties:
1535
1577
  if show_error:
1536
1578
  self.logger.error("Key -> '%s' is not in result properties!", key)
@@ -1669,8 +1711,8 @@ class OTCS:
1669
1711
  Defaults to "data".
1670
1712
 
1671
1713
  Returns:
1672
- list | None:
1673
- Value list of the item with the given key, or None if no value is found.
1714
+ iter:
1715
+ Iterator object for iterating through the values.
1674
1716
 
1675
1717
  """
1676
1718
 
@@ -1683,9 +1725,9 @@ class OTCS:
1683
1725
  # than creating a list with all values at once.
1684
1726
  # This is especially important for large result sets.
1685
1727
  yield from (
1686
- item[data_name][property_name]
1728
+ item[data_name][property_name] if property_name else item[data_name]
1687
1729
  for item in response["results"]
1688
- if isinstance(item.get(data_name), dict) and property_name in item[data_name]
1730
+ if isinstance(item.get(data_name), dict) and (not property_name or property_name in item[data_name])
1689
1731
  )
1690
1732
 
1691
1733
  # end method definition
@@ -1835,6 +1877,31 @@ class OTCS:
1835
1877
 
1836
1878
  request_url = self.config()["authenticationUrl"]
1837
1879
 
1880
+ if self._otds_token and not revalidate:
1881
+ self.logger.debug(
1882
+ "Requesting OTCS ticket with existing OTDS token; calling -> %s",
1883
+ request_url,
1884
+ )
1885
+ # Add the OTDS token to the request headers:
1886
+ request_header = REQUEST_FORM_HEADERS | {"Authorization": f"Bearer {self._otds_token}"}
1887
+
1888
+ try:
1889
+ response = requests.get(
1890
+ url=request_url,
1891
+ headers=request_header,
1892
+ timeout=10,
1893
+ )
1894
+ if response.ok:
1895
+ # read the ticket from the response header:
1896
+ otcs_ticket = response.headers.get("OTCSTicket")
1897
+
1898
+ except requests.exceptions.RequestException as exception:
1899
+ self.logger.warning(
1900
+ "Unable to connect to -> %s; error -> %s",
1901
+ request_url,
1902
+ str(exception),
1903
+ )
1904
+
1838
1905
  if self._otds_ticket and not revalidate:
1839
1906
  self.logger.debug(
1840
1907
  "Requesting OTCS ticket with existing OTDS ticket; calling -> %s",
@@ -2138,7 +2205,8 @@ class OTCS:
2138
2205
  """Apply Content Server administration settings from XML file.
2139
2206
 
2140
2207
  Args:
2141
- xml_file_path (str): name + path of the XML settings file
2208
+ xml_file_path (str):
2209
+ The fully qualified path to the XML settings file.
2142
2210
 
2143
2211
  Returns:
2144
2212
  dict | None:
@@ -2178,7 +2246,7 @@ class OTCS:
2178
2246
  headers=request_header,
2179
2247
  files=llconfig_file,
2180
2248
  timeout=None,
2181
- success_message="Admin settings in file -> '{}' have been applied".format(
2249
+ success_message="Admin settings in file -> '{}' have been applied.".format(
2182
2250
  xml_file_path,
2183
2251
  ),
2184
2252
  failure_message="Failed to import settings file -> '{}'".format(
@@ -2733,7 +2801,13 @@ class OTCS:
2733
2801
 
2734
2802
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_current_user")
2735
2803
  def get_current_user(self) -> dict | None:
2736
- """Get the current authenticated user."""
2804
+ """Get the current authenticated user.
2805
+
2806
+ Returns:
2807
+ dict | None:
2808
+ Information for the current (authenticated) user.
2809
+
2810
+ """
2737
2811
 
2738
2812
  request_url = self.config()["authenticationUrl"]
2739
2813
 
@@ -2749,7 +2823,7 @@ class OTCS:
2749
2823
  method="GET",
2750
2824
  headers=request_header,
2751
2825
  timeout=None,
2752
- failure_message="Failed to get current user!",
2826
+ failure_message="Failed to get current user",
2753
2827
  )
2754
2828
 
2755
2829
  # end method definition
@@ -2764,6 +2838,7 @@ class OTCS:
2764
2838
  email: str,
2765
2839
  title: str,
2766
2840
  base_group: int,
2841
+ phone: str = "",
2767
2842
  privileges: list | None = None,
2768
2843
  user_type: int = 0,
2769
2844
  ) -> dict | None:
@@ -2784,7 +2859,9 @@ class OTCS:
2784
2859
  The title of the user.
2785
2860
  base_group (int):
2786
2861
  The base group id of the user (e.g. department)
2787
- privileges (list, optional):
2862
+ phone (str, optional):
2863
+ The business phone number of the user.
2864
+ privileges (list | None, optional):
2788
2865
  Possible values are Login, Public Access, Content Manager,
2789
2866
  Modify Users, Modify Groups, User Admin Rights,
2790
2867
  Grant Discovery, System Admin Rights
@@ -2808,6 +2885,7 @@ class OTCS:
2808
2885
  "first_name": first_name,
2809
2886
  "last_name": last_name,
2810
2887
  "business_email": email,
2888
+ "business_phone": phone,
2811
2889
  "title": title,
2812
2890
  "group_id": base_group,
2813
2891
  "privilege_login": ("Login" in privileges),
@@ -2987,11 +3065,14 @@ class OTCS:
2987
3065
  """Update a user with a profile photo (which must be an existing node).
2988
3066
 
2989
3067
  Args:
2990
- user_id (int): The ID of the user.
2991
- photo_id (int): The node ID of the photo.
3068
+ user_id (int):
3069
+ The ID of the user.
3070
+ photo_id (int):
3071
+ The node ID of the photo.
2992
3072
 
2993
3073
  Returns:
2994
- dict | None: Node information or None if photo node is not found.
3074
+ dict | None:
3075
+ Node information or None if photo node is not found.
2995
3076
 
2996
3077
  """
2997
3078
 
@@ -3027,10 +3108,12 @@ class OTCS:
3027
3108
  that was introduced with version 23.4.
3028
3109
 
3029
3110
  Args:
3030
- user_name (str): user to test (login name)
3111
+ user_name (str):
3112
+ The user to test (login name) for proxy.
3031
3113
 
3032
3114
  Returns:
3033
- bool: True is user is proxy of current user. False if not.
3115
+ bool:
3116
+ True is user is proxy of current user. False if not.
3034
3117
 
3035
3118
  """
3036
3119
 
@@ -3956,7 +4039,7 @@ class OTCS:
3956
4039
  method="GET",
3957
4040
  headers=request_header,
3958
4041
  timeout=REQUEST_TIMEOUT,
3959
- failure_message="Failed to get system usage privileges.",
4042
+ failure_message="Failed to get system usage privileges",
3960
4043
  )
3961
4044
 
3962
4045
  if response:
@@ -4160,7 +4243,7 @@ class OTCS:
4160
4243
  method="GET",
4161
4244
  headers=request_header,
4162
4245
  timeout=REQUEST_TIMEOUT,
4163
- failure_message="Failed to get system usage privileges.",
4246
+ failure_message="Failed to get system usage privileges",
4164
4247
  )
4165
4248
 
4166
4249
  if response:
@@ -4306,9 +4389,9 @@ class OTCS:
4306
4389
  def get_node(
4307
4390
  self,
4308
4391
  node_id: int,
4309
- fields: (str | list) = "properties", # per default we just get the most important information
4392
+ fields: str | list = "properties", # per default we just get the most important information
4310
4393
  metadata: bool = False,
4311
- timeout: int = REQUEST_TIMEOUT,
4394
+ timeout: float = REQUEST_TIMEOUT,
4312
4395
  ) -> dict | None:
4313
4396
  """Get a node based on the node ID.
4314
4397
 
@@ -4335,7 +4418,7 @@ class OTCS:
4335
4418
  The metadata will be returned under `results.metadata`, `metadata_map`,
4336
4419
  and `metadata_order`.
4337
4420
  Defaults to False.
4338
- timeout (int, optional):
4421
+ timeout (float, optional):
4339
4422
  Timeout for the request in seconds. Defaults to `REQUEST_TIMEOUT`.
4340
4423
 
4341
4424
  Returns:
@@ -4986,9 +5069,9 @@ class OTCS:
4986
5069
  show_hidden (bool, optional):
4987
5070
  Whether to list hidden items. Defaults to False.
4988
5071
  limit (int, optional):
4989
- The maximum number of results to return. Defaults to 100.
5072
+ The maximum number of results to return (page size). Defaults to 100.
4990
5073
  page (int, optional):
4991
- The page of results to retrieve. Defaults to 1 (first page).
5074
+ The page of results to retrieve (page number). Defaults to 1 (first page).
4992
5075
  fields (str | list, optional):
4993
5076
  Which fields to retrieve.
4994
5077
  This can have a significant impact on performance.
@@ -5282,7 +5365,7 @@ class OTCS:
5282
5365
  # end method definition
5283
5366
 
5284
5367
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="lookup_node")
5285
- def lookup_node(
5368
+ def lookup_nodes(
5286
5369
  self,
5287
5370
  parent_node_id: int,
5288
5371
  category: str,
@@ -5290,7 +5373,7 @@ class OTCS:
5290
5373
  value: str,
5291
5374
  attribute_set: str | None = None,
5292
5375
  ) -> dict | None:
5293
- """Lookup the node under a parent node that has a specified value in a category attribute.
5376
+ """Lookup nodes under a parent node that have a specified value in a category attribute.
5294
5377
 
5295
5378
  Args:
5296
5379
  parent_node_id (int):
@@ -5306,10 +5389,12 @@ class OTCS:
5306
5389
 
5307
5390
  Returns:
5308
5391
  dict | None:
5309
- Node wrapped in dictionary with "results" key or None if the REST API fails.
5392
+ Node(s) wrapped in dictionary with "results" key or None if the REST API fails.
5310
5393
 
5311
5394
  """
5312
5395
 
5396
+ results = {"results": []}
5397
+
5313
5398
  # get_subnodes_iterator() returns a python generator that we use for iterating over all nodes
5314
5399
  # in an efficient way avoiding to retrieve all nodes at once (which could be a large number):
5315
5400
  for node in self.get_subnodes_iterator(
@@ -5338,11 +5423,10 @@ class OTCS:
5338
5423
  continue
5339
5424
  category_key = next(iter(category_schema))
5340
5425
 
5341
- attribute_schema = next(
5342
- (cat_elem for cat_elem in category_schema.values() if cat_elem.get("name") == attribute),
5343
- None,
5344
- )
5345
- if not attribute_schema:
5426
+ # There can be multiple attributes with the same name in a category
5427
+ # if the category has sets:
5428
+ attribute_schemas = [cat_elem for cat_elem in category_schema.values() if cat_elem.get("name") == attribute]
5429
+ if not attribute_schemas:
5346
5430
  self.logger.debug(
5347
5431
  "Node -> '%s' (%s) does not have attribute -> '%s'. Skipping...",
5348
5432
  node_name,
@@ -5350,69 +5434,81 @@ class OTCS:
5350
5434
  attribute,
5351
5435
  )
5352
5436
  continue
5353
- attribute_key = attribute_schema["key"]
5354
- # Split the attribute key once (1) at the first underscore from the right.
5355
- # rsplit delivers a list and [-1] delivers the last list item:
5356
- attribute_id = attribute_key.rsplit("_", 1)[-1]
5357
-
5358
- if attribute_set:
5359
- set_schema = next(
5360
- (
5361
- cat_elem
5362
- for cat_elem in category_schema.values()
5363
- if cat_elem.get("name") == attribute_set and cat_elem.get("persona") == "set"
5364
- ),
5365
- None,
5366
- )
5367
- if not set_schema:
5368
- self.logger.debug(
5369
- "Node -> '%s' (%s) does not have attribute set -> '%s'. Skipping...",
5370
- node_name,
5371
- node_id,
5372
- attribute_set,
5373
- )
5374
- continue
5375
- set_key = set_schema["key"]
5376
- else:
5377
- set_schema = None
5378
- set_key = None
5379
5437
 
5380
- prefix = set_key + "_" if set_key else category_key + "_"
5438
+ # Traverse the attribute schemas with the matching attribute name:
5439
+ for attribute_schema in attribute_schemas:
5440
+ attribute_key = attribute_schema["key"]
5441
+ # Split the attribute key once (1) at the first underscore from the right.
5442
+ # rsplit delivers a list and [-1] delivers the last list item:
5443
+ attribute_id = attribute_key.rsplit("_", 1)[-1]
5381
5444
 
5382
- data = node["data"]["categories"]
5383
- for cat_data in data:
5384
- if set_key:
5385
- for i in range(1, int(set_schema["multi_value_length_max"])):
5386
- key = prefix + str(i) + "_" + attribute_id
5445
+ if attribute_set: # is the attribute_set parameter provided?
5446
+ set_schema = next(
5447
+ (
5448
+ cat_elem
5449
+ for cat_elem in category_schema.values()
5450
+ if cat_elem.get("name") == attribute_set and cat_elem.get("persona") == "set"
5451
+ ),
5452
+ None,
5453
+ )
5454
+ if not set_schema:
5455
+ self.logger.debug(
5456
+ "Node -> '%s' (%s) does not have attribute set -> '%s'. Skipping...",
5457
+ node_name,
5458
+ node_id,
5459
+ attribute_set,
5460
+ )
5461
+ continue
5462
+ set_key = set_schema["key"]
5463
+ else: # no attribute set value provided via the attribute_set parameter:
5464
+ if "_x_" in attribute_key:
5465
+ # The lookup does not include a set name but this attribute key
5466
+ # belongs to a set attribute - so we can skip it:
5467
+ continue
5468
+ set_schema = None
5469
+ set_key = None
5470
+
5471
+ prefix = set_key + "_" if set_key else category_key + "_"
5472
+
5473
+ data = node["data"]["categories"]
5474
+ for cat_data in data:
5475
+ if set_key:
5476
+ for i in range(1, int(set_schema["multi_value_length_max"])):
5477
+ key = prefix + str(i) + "_" + attribute_id
5478
+ attribute_value = cat_data.get(key)
5479
+ if not attribute_value:
5480
+ break
5481
+ # Is it a multi-value attribute (i.e. a list of values)?
5482
+ if isinstance(attribute_value, list):
5483
+ if value in attribute_value:
5484
+ # Create a "results" dict that is compatible with normal REST calls
5485
+ # to not break get_result_value() method that may be called on the result:
5486
+ results["results"].append(node)
5487
+ elif value == attribute_value:
5488
+ # Create a results dict that is compatible with normal REST calls
5489
+ # to not break get_result_value() method that may be called on the result:
5490
+ results["results"].append(node)
5491
+ # end if set_key
5492
+ else:
5493
+ key = prefix + attribute_id
5387
5494
  attribute_value = cat_data.get(key)
5388
5495
  if not attribute_value:
5389
- break
5496
+ continue
5390
5497
  # Is it a multi-value attribute (i.e. a list of values)?
5391
5498
  if isinstance(attribute_value, list):
5392
5499
  if value in attribute_value:
5393
5500
  # Create a "results" dict that is compatible with normal REST calls
5394
5501
  # to not break get_result_value() method that may be called on the result:
5395
- return {"results": node}
5502
+ results["results"].append(node)
5503
+ # If not a multi-value attribute, check for equality:
5396
5504
  elif value == attribute_value:
5397
5505
  # Create a results dict that is compatible with normal REST calls
5398
5506
  # to not break get_result_value() method that may be called on the result:
5399
- return {"results": node}
5400
- else:
5401
- key = prefix + attribute_id
5402
- attribute_value = cat_data.get(key)
5403
- if not attribute_value:
5404
- break
5405
- if isinstance(attribute_value, list):
5406
- if value in attribute_value:
5407
- # Create a "results" dict that is compatible with normal REST calls
5408
- # to not break get_result_value() method that may be called on the result:
5409
- return {"results": node}
5410
- elif value == attribute_value:
5411
- # Create a results dict that is compatible with normal REST calls
5412
- # to not break get_result_value() method that may be called on the result:
5413
- return {"results": node}
5414
- # end for cat_data, cat_schema in zip(data, schema)
5415
- # end for node in nodes
5507
+ results["results"].append(node)
5508
+ # end if set_key ... else
5509
+ # end for cat_data in data:
5510
+ # end for attribute_schema in attribute_schemas:
5511
+ # end for node in self.get_subnodes_iterator()
5416
5512
 
5417
5513
  self.logger.debug(
5418
5514
  "Couldn't find a node with the value -> '%s' in the attribute -> '%s' of category -> '%s' in parent with node ID -> %d.",
@@ -5422,7 +5518,7 @@ class OTCS:
5422
5518
  parent_node_id,
5423
5519
  )
5424
5520
 
5425
- return {"results": []}
5521
+ return results if results["results"] else None
5426
5522
 
5427
5523
  # end method definition
5428
5524
 
@@ -6322,14 +6418,14 @@ class OTCS:
6322
6418
  def get_volume(
6323
6419
  self,
6324
6420
  volume_type: int,
6325
- timeout: int = REQUEST_TIMEOUT,
6421
+ timeout: float = REQUEST_TIMEOUT,
6326
6422
  ) -> dict | None:
6327
6423
  """Get Volume information based on the volume type ID.
6328
6424
 
6329
6425
  Args:
6330
6426
  volume_type (int):
6331
6427
  The ID of the volume type.
6332
- timeout (int, optional):
6428
+ timeout (float | None, optional):
6333
6429
  The timeout for the request in seconds.
6334
6430
 
6335
6431
  Returns:
@@ -6592,6 +6688,7 @@ class OTCS:
6592
6688
  external_modify_date: str | None = None,
6593
6689
  external_create_date: str | None = None,
6594
6690
  extract_zip: bool = False,
6691
+ replace_existing: bool = False,
6595
6692
  show_error: bool = True,
6596
6693
  ) -> dict | None:
6597
6694
  """Fetch a file from a URL or local filesystem and uploads it to a OTCS parent.
@@ -6633,6 +6730,9 @@ class OTCS:
6633
6730
  extract_zip (bool, optional):
6634
6731
  If True, automatically extract ZIP files and upload extracted directory. If False,
6635
6732
  upload the unchanged Zip file.
6733
+ replace_existing (bool, optional):
6734
+ If True, replaces an existing file with the same name in the target folder. If False,
6735
+ the upload will fail if a file with the same name already exists.
6636
6736
  show_error (bool, optional):
6637
6737
  If True, treats the upload failure as an error. If False, no error is shown (useful if the file already exists).
6638
6738
 
@@ -6683,8 +6783,7 @@ class OTCS:
6683
6783
  file_content = response.content
6684
6784
 
6685
6785
  # If path_or_url specifies a directory or a zip file we want to extract
6686
- # it and then defer the upload to upload_directory_to_parent()
6687
-
6786
+ # it and then defer the upload to upload_directory_to_parent():
6688
6787
  elif os.path.exists(file_url) and (
6689
6788
  ((file_url.endswith(".zip") or mime_type == "application/x-zip-compressed") and extract_zip)
6690
6789
  or os.path.isdir(file_url)
@@ -6692,6 +6791,7 @@ class OTCS:
6692
6791
  return self.upload_directory_to_parent(
6693
6792
  parent_id=parent_id,
6694
6793
  file_path=file_url,
6794
+ replace_existing=replace_existing,
6695
6795
  )
6696
6796
 
6697
6797
  elif os.path.exists(file_url):
@@ -6786,7 +6886,7 @@ class OTCS:
6786
6886
  # end method definition
6787
6887
 
6788
6888
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="upload_directory_to_parent")
6789
- def upload_directory_to_parent(self, parent_id: int, file_path: str) -> dict | None:
6889
+ def upload_directory_to_parent(self, parent_id: int, file_path: str, replace_existing: bool = True) -> dict | None:
6790
6890
  """Upload a directory or an uncompressed zip file to Content Server.
6791
6891
 
6792
6892
  IMPORTANT: if the path ends in a file then we assume it is a ZIP file!
@@ -6796,6 +6896,8 @@ class OTCS:
6796
6896
  ID of the parent in Content Server.
6797
6897
  file_path (str):
6798
6898
  File system path to the directory or zip file.
6899
+ replace_existing (bool, optional):
6900
+ If True, existing files are replaced by uploading a new version.
6799
6901
 
6800
6902
  Returns:
6801
6903
  dict | None:
@@ -6893,21 +6995,25 @@ class OTCS:
6893
6995
  response = self.upload_directory_to_parent(
6894
6996
  parent_id=current_parent_id,
6895
6997
  file_path=full_file_path,
6998
+ replace_existing=replace_existing,
6896
6999
  )
6897
7000
  if response and not first_response:
6898
7001
  first_response = response.copy()
6899
7002
  continue
7003
+ # Check if the file already exists:
6900
7004
  response = self.get_node_by_parent_and_name(
6901
7005
  parent_id=current_parent_id,
6902
7006
  name=file_name,
6903
7007
  )
6904
7008
  if not response or not response["results"]:
7009
+ # File does not yet exist - upload new document:
6905
7010
  response = self.upload_file_to_parent(
6906
7011
  parent_id=current_parent_id,
6907
7012
  file_url=full_file_path,
6908
7013
  file_name=file_name,
6909
7014
  )
6910
- else:
7015
+ elif replace_existing:
7016
+ # Document does already exist - upload a new version if replace existing is requested:
6911
7017
  existing_document_id = self.get_result_value(
6912
7018
  response=response,
6913
7019
  key="id",
@@ -7429,8 +7535,10 @@ class OTCS:
7429
7535
  node_id: int,
7430
7536
  file_path: str,
7431
7537
  version_number: str | int = "",
7538
+ chunk_size: int = 8192,
7539
+ overwrite: bool = True,
7432
7540
  ) -> bool:
7433
- """Download a document from OTCS to local file system.
7541
+ """Download a document (version) from OTCS to local file system.
7434
7542
 
7435
7543
  Args:
7436
7544
  node_id (int):
@@ -7440,6 +7548,12 @@ class OTCS:
7440
7548
  version_number (str | int, optional):
7441
7549
  The version of the document to download.
7442
7550
  If version = "" then download the latest version.
7551
+ chunk_size (int, optional):
7552
+ The chunk size to use when downloading the document in bytes.
7553
+ Default is 8192 bytes.
7554
+ overwrite (bool, optional):
7555
+ If True, overwrite the file if it already exists. If False, do not overwrite
7556
+ and return False if the file already exists.
7443
7557
 
7444
7558
  Returns:
7445
7559
  bool:
@@ -7449,24 +7563,26 @@ class OTCS:
7449
7563
  """
7450
7564
 
7451
7565
  if not version_number:
7452
- response = self.get_latest_document_version(node_id)
7453
- if not response:
7454
- self.logger.error(
7455
- "Cannot get latest version of document with ID -> %d",
7456
- node_id,
7457
- )
7458
- return False
7459
- version_number = response["data"]["version_number"]
7460
-
7461
- request_url = self.config()["nodesUrlv2"] + "/" + str(node_id) + "/versions/" + str(version_number) + "/content"
7566
+ # we retrieve the latest version - using V1 REST API. V2 has issues with downloading files:
7567
+ request_url = self.config()["nodesUrl"] + "/" + str(node_id) + "/content"
7568
+ self.logger.debug(
7569
+ "Download document with node ID -> %d (latest version); calling -> %s",
7570
+ node_id,
7571
+ request_url,
7572
+ )
7573
+ else:
7574
+ # we retrieve the given version - using V1 REST API. V2 has issues with downloading files:
7575
+ request_url = (
7576
+ self.config()["nodesUrl"] + "/" + str(node_id) + "/versions/" + str(version_number) + "/content"
7577
+ )
7578
+ self.logger.debug(
7579
+ "Download document with node ID -> %d and version number -> %d; calling -> %s",
7580
+ node_id,
7581
+ version_number,
7582
+ request_url,
7583
+ )
7462
7584
  request_header = self.request_download_header()
7463
7585
 
7464
- self.logger.debug(
7465
- "Download document with node ID -> %d; calling -> %s",
7466
- node_id,
7467
- request_url,
7468
- )
7469
-
7470
7586
  response = self.do_request(
7471
7587
  url=request_url,
7472
7588
  method="GET",
@@ -7482,6 +7598,26 @@ class OTCS:
7482
7598
  if response is None:
7483
7599
  return False
7484
7600
 
7601
+ total_size = int(response.headers["Content-Length"]) if "Content-Length" in response.headers else None
7602
+
7603
+ content_encoding = response.headers.get("Content-Encoding", "").lower()
7604
+ is_compressed = content_encoding in ("gzip", "deflate", "br")
7605
+
7606
+ self.logger.debug(
7607
+ "Downloading document with node ID -> %d to file -> '%s'; total size -> %s bytes; content encoding -> '%s'",
7608
+ node_id,
7609
+ file_path,
7610
+ total_size,
7611
+ content_encoding,
7612
+ )
7613
+
7614
+ if os.path.exists(file_path) and not overwrite:
7615
+ self.logger.warning(
7616
+ "File -> '%s' already exists and overwrite is set to False, not downloading document.",
7617
+ file_path,
7618
+ )
7619
+ return False
7620
+
7485
7621
  directory = os.path.dirname(file_path)
7486
7622
  if not os.path.exists(directory):
7487
7623
  self.logger.info(
@@ -7490,12 +7626,31 @@ class OTCS:
7490
7626
  )
7491
7627
  os.makedirs(directory)
7492
7628
 
7629
+ bytes_downloaded = 0
7493
7630
  try:
7494
7631
  with open(file_path, "wb") as download_file:
7495
- download_file.writelines(response.iter_content(chunk_size=1024))
7496
- except Exception:
7632
+ for chunk in response.iter_content(chunk_size=chunk_size):
7633
+ if chunk:
7634
+ download_file.write(chunk)
7635
+ bytes_downloaded += len(chunk)
7636
+
7637
+ except Exception as e:
7638
+ self.logger.error(
7639
+ "Error while writing content to file -> %s after %d bytes downloaded; error -> %s",
7640
+ file_path,
7641
+ bytes_downloaded,
7642
+ str(e),
7643
+ )
7644
+ return False
7645
+
7646
+ # if we have a total size and the content is not compressed
7647
+ # we can do a sanity check if the downloaded size matches
7648
+ # the expected size:
7649
+ if total_size and not is_compressed and bytes_downloaded != total_size:
7497
7650
  self.logger.error(
7498
- "Error while writing content to file -> %s",
7651
+ "Downloaded size (%d bytes) does not match expected size (%d bytes) for file -> '%s'",
7652
+ bytes_downloaded,
7653
+ total_size,
7499
7654
  file_path,
7500
7655
  )
7501
7656
  return False
@@ -7592,9 +7747,11 @@ class OTCS:
7592
7747
  search_term: str,
7593
7748
  look_for: str = "complexQuery",
7594
7749
  modifier: str = "",
7750
+ within: str = "all",
7595
7751
  slice_id: int = 0,
7596
7752
  query_id: int = 0,
7597
7753
  template_id: int = 0,
7754
+ location_id: int | None = None,
7598
7755
  limit: int = 100,
7599
7756
  page: int = 1,
7600
7757
  ) -> dict | None:
@@ -7619,12 +7776,20 @@ class OTCS:
7619
7776
  - 'wordendswith'
7620
7777
  If not specified or any value other than these options is given,
7621
7778
  it is ignored.
7779
+ within (str, optional):
7780
+ The scope of the search. Possible values are:
7781
+ - 'all': search in content and in metadata (default)
7782
+ - 'content': search only in document content
7783
+ - 'metadata': search only in item metadata
7622
7784
  slice_id (int, optional):
7623
7785
  The ID of an existing search slice.
7624
7786
  query_id (int, optional):
7625
7787
  The ID of a saved search query.
7626
7788
  template_id (int, optional):
7627
7789
  The ID of a saved search template.
7790
+ location_id (int | None, optional):
7791
+ The ID of a folder or workspace to start a search from here.
7792
+ None = unrestricted search (default).
7628
7793
  limit (int, optional):
7629
7794
  The maximum number of results to return. Default is 100.
7630
7795
  page (int, optional):
@@ -7809,6 +7974,7 @@ class OTCS:
7809
7974
  search_post_body = {
7810
7975
  "where": search_term,
7811
7976
  "lookfor": look_for,
7977
+ "within": within,
7812
7978
  "page": page,
7813
7979
  "limit": limit,
7814
7980
  }
@@ -7821,6 +7987,8 @@ class OTCS:
7821
7987
  search_post_body["query_id"] = query_id
7822
7988
  if template_id > 0:
7823
7989
  search_post_body["template_id"] = template_id
7990
+ if location_id is not None:
7991
+ search_post_body["location_id1"] = location_id
7824
7992
 
7825
7993
  request_url = self.config()["searchUrl"]
7826
7994
  request_header = self.request_form_header()
@@ -7847,10 +8015,13 @@ class OTCS:
7847
8015
  search_term: str,
7848
8016
  look_for: str = "complexQuery",
7849
8017
  modifier: str = "",
8018
+ within: str = "all",
7850
8019
  slice_id: int = 0,
7851
8020
  query_id: int = 0,
7852
8021
  template_id: int = 0,
8022
+ location_id: int | None = None,
7853
8023
  page_size: int = 100,
8024
+ limit: int | None = None,
7854
8025
  ) -> iter:
7855
8026
  """Get an iterator object to traverse all search results for a given search.
7856
8027
 
@@ -7878,19 +8049,31 @@ class OTCS:
7878
8049
  Defines a modifier for the search. Possible values are:
7879
8050
  - 'synonymsof', 'relatedto', 'soundslike', 'wordbeginswith', 'wordendswith'.
7880
8051
  If not specified or any value other than these options is given, it is ignored.
8052
+ within (str, optional):
8053
+ The scope of the search. Possible values are:
8054
+ - 'all': search in content and in metadata (default)
8055
+ - 'content': search only in document content
8056
+ - 'metadata': search only in item metadata
7881
8057
  slice_id (int, optional):
7882
8058
  The ID of an existing search slice.
7883
8059
  query_id (int, optional):
7884
8060
  The ID of a saved search query.
7885
8061
  template_id (int, optional):
7886
8062
  The ID of a saved search template.
8063
+ location_id (int | None, optional):
8064
+ The ID of a folder or workspace to start a search from here.
8065
+ None = unrestricted search (default).
7887
8066
  page_size (int, optional):
7888
8067
  The maximum number of results to return. Default is 100.
7889
- For the iterator this ois basically the chunk size.
8068
+ For the iterator this is basically the chunk size.
8069
+ limit (int | None = None), optional):
8070
+ The maximum number of results to return in total.
8071
+ If None (default) all results are returned.
8072
+ If a number is provided only up to this number of results is returned.
7890
8073
 
7891
8074
  Returns:
7892
- dict | None:
7893
- The search response as a dictionary if successful, or None if the search fails.
8075
+ iter:
8076
+ The search response iterator object.
7894
8077
 
7895
8078
  """
7896
8079
 
@@ -7899,9 +8082,11 @@ class OTCS:
7899
8082
  search_term=search_term,
7900
8083
  look_for=look_for,
7901
8084
  modifier=modifier,
8085
+ within=within,
7902
8086
  slice_id=slice_id,
7903
8087
  query_id=query_id,
7904
8088
  template_id=template_id,
8089
+ location_id=location_id,
7905
8090
  limit=1,
7906
8091
  page=1,
7907
8092
  )
@@ -7912,6 +8097,9 @@ class OTCS:
7912
8097
  return
7913
8098
 
7914
8099
  number_of_results = response["collection"]["paging"]["total_count"]
8100
+ if limit and number_of_results > limit:
8101
+ number_of_results = limit
8102
+
7915
8103
  if not number_of_results:
7916
8104
  self.logger.debug(
7917
8105
  "Search -> '%s' does not have results! Cannot iterate over results.",
@@ -7934,9 +8122,11 @@ class OTCS:
7934
8122
  search_term=search_term,
7935
8123
  look_for=look_for,
7936
8124
  modifier=modifier,
8125
+ within=within,
7937
8126
  slice_id=slice_id,
7938
8127
  query_id=query_id,
7939
8128
  template_id=template_id,
8129
+ location_id=location_id,
7940
8130
  limit=page_size,
7941
8131
  page=page,
7942
8132
  )
@@ -7980,7 +8170,7 @@ class OTCS:
7980
8170
  request_header = self.cookie()
7981
8171
 
7982
8172
  self.logger.debug(
7983
- "Get external system connection -> %s; calling -> %s",
8173
+ "Get external system connection -> '%s'; calling -> %s",
7984
8174
  connection_name,
7985
8175
  request_url,
7986
8176
  )
@@ -8170,7 +8360,7 @@ class OTCS:
8170
8360
  # end method definition
8171
8361
 
8172
8362
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="deploy_workbench")
8173
- def deploy_workbench(self, workbench_id: int) -> dict | None:
8363
+ def deploy_workbench(self, workbench_id: int) -> tuple[dict | None, int]:
8174
8364
  """Deploy an existing Workbench.
8175
8365
 
8176
8366
  Args:
@@ -8180,6 +8370,8 @@ class OTCS:
8180
8370
  Returns:
8181
8371
  dict | None:
8182
8372
  The deploy response or None if the deployment fails.
8373
+ int:
8374
+ Error count. Should be 0 if fully successful.
8183
8375
 
8184
8376
  Example response:
8185
8377
  {
@@ -8227,8 +8419,12 @@ class OTCS:
8227
8419
  ),
8228
8420
  )
8229
8421
 
8422
+ # Transport packages canalso partly fail to deploy.
8423
+ # For such cases we determine the number of errors.
8424
+ error_count = 0
8425
+
8230
8426
  if not response or "results" not in response:
8231
- return None
8427
+ return (None, 0)
8232
8428
 
8233
8429
  try:
8234
8430
  error_count = response["results"]["data"]["status"]["error_count"]
@@ -8239,7 +8435,7 @@ class OTCS:
8239
8435
  else:
8240
8436
  success_count = response["results"]["data"]["status"]["success_count"]
8241
8437
  self.logger.info(
8242
- "Transport successfully deployed %d workbench items",
8438
+ "Transport successfully deployed %d workbench items.",
8243
8439
  success_count,
8244
8440
  )
8245
8441
 
@@ -8254,7 +8450,7 @@ class OTCS:
8254
8450
  except Exception as e:
8255
8451
  self.logger.debug(str(e))
8256
8452
 
8257
- return response
8453
+ return (response, error_count)
8258
8454
 
8259
8455
  # end method definition
8260
8456
 
@@ -8309,11 +8505,18 @@ class OTCS:
8309
8505
  if extractions is None:
8310
8506
  extractions = []
8311
8507
 
8508
+ while not self.is_ready():
8509
+ self.logger.info(
8510
+ "OTCS is not ready. Cannot deploy transport -> '%s' to OTCS. Waiting 30 seconds and retry...",
8511
+ package_name,
8512
+ )
8513
+ time.sleep(30)
8514
+
8312
8515
  # Preparation: get volume IDs for Transport Warehouse (root volume and Transport Packages)
8313
8516
  response = self.get_volume(volume_type=self.VOLUME_TYPE_TRANSPORT_WAREHOUSE)
8314
8517
  transport_root_volume_id = self.get_result_value(response=response, key="id")
8315
8518
  if not transport_root_volume_id:
8316
- self.logger.error("Failed to retrieve transport root volume")
8519
+ self.logger.error("Failed to retrieve transport root volume!")
8317
8520
  return None
8318
8521
  self.logger.debug(
8319
8522
  "Transport root volume ID -> %d",
@@ -8326,7 +8529,7 @@ class OTCS:
8326
8529
  )
8327
8530
  transport_package_volume_id = self.get_result_value(response=response, key="id")
8328
8531
  if not transport_package_volume_id:
8329
- self.logger.error("Failed to retrieve transport package volume")
8532
+ self.logger.error("Failed to retrieve transport package volume!")
8330
8533
  return None
8331
8534
  self.logger.debug(
8332
8535
  "Transport package volume ID -> %d",
@@ -8345,20 +8548,20 @@ class OTCS:
8345
8548
  package_id = self.get_result_value(response=response, key="id")
8346
8549
  if package_id:
8347
8550
  self.logger.debug(
8348
- "Transport package -> '%s' does already exist; existing package ID -> %d",
8551
+ "Transport package -> '%s' does already exist; existing package ID -> %d.",
8349
8552
  package_name,
8350
8553
  package_id,
8351
8554
  )
8352
8555
  else:
8353
8556
  self.logger.debug(
8354
- "Transport package -> '%s' does not yet exist, loading from -> %s",
8557
+ "Transport package -> '%s' does not yet exist, loading from -> '%s'...",
8355
8558
  package_name,
8356
8559
  package_url,
8357
8560
  )
8358
8561
  # If we have string replacements configured execute them now:
8359
8562
  if replacements:
8360
8563
  self.logger.debug(
8361
- "Transport -> '%s' has replacements -> %s",
8564
+ "Transport -> '%s' has replacements -> %s.",
8362
8565
  package_name,
8363
8566
  str(replacements),
8364
8567
  )
@@ -8368,13 +8571,13 @@ class OTCS:
8368
8571
  )
8369
8572
  else:
8370
8573
  self.logger.debug(
8371
- "Transport -> '%s' has no replacements!",
8574
+ "Transport -> '%s' has no replacements.",
8372
8575
  package_name,
8373
8576
  )
8374
8577
  # If we have data extractions configured execute them now:
8375
8578
  if extractions:
8376
8579
  self.logger.debug(
8377
- "Transport -> '%s' has extractions -> %s",
8580
+ "Transport -> '%s' has extractions -> %s.",
8378
8581
  package_name,
8379
8582
  str(extractions),
8380
8583
  )
@@ -8383,7 +8586,7 @@ class OTCS:
8383
8586
  extractions=extractions,
8384
8587
  )
8385
8588
  else:
8386
- self.logger.debug("Transport -> '%s' has no extractions!", package_name)
8589
+ self.logger.debug("Transport -> '%s' has no extractions.", package_name)
8387
8590
 
8388
8591
  # Upload package to Transport Warehouse:
8389
8592
  response = self.upload_file_to_volume(
@@ -8395,7 +8598,7 @@ class OTCS:
8395
8598
  package_id = self.get_result_value(response=response, key="id")
8396
8599
  if not package_id:
8397
8600
  self.logger.error(
8398
- "Failed to upload transport package -> %s",
8601
+ "Failed to upload transport package -> '%s'!",
8399
8602
  package_url,
8400
8603
  )
8401
8604
  return None
@@ -8438,7 +8641,7 @@ class OTCS:
8438
8641
  workbench_id = self.get_result_value(response=response, key="id")
8439
8642
  if workbench_id:
8440
8643
  self.logger.debug(
8441
- "Workbench -> '%s' does already exist but is not successfully deployed; existing workbench ID -> %d",
8644
+ "Workbench -> '%s' does already exist but is not successfully deployed; existing workbench ID -> %d.",
8442
8645
  workbench_name,
8443
8646
  workbench_id,
8444
8647
  )
@@ -8454,14 +8657,14 @@ class OTCS:
8454
8657
  )
8455
8658
  return None
8456
8659
  self.logger.debug(
8457
- "Successfully created workbench -> '%s'; new workbench ID -> %d",
8660
+ "Successfully created workbench -> '%s'; new workbench ID -> %d.",
8458
8661
  workbench_name,
8459
8662
  workbench_id,
8460
8663
  )
8461
8664
 
8462
8665
  # Step 3: Unpack Transport Package to Workbench
8463
8666
  self.logger.debug(
8464
- "Unpack transport package -> '%s' (%d) to workbench -> '%s' (%d)",
8667
+ "Unpack transport package -> '%s' (%d) to workbench -> '%s' (%d)...",
8465
8668
  package_name,
8466
8669
  package_id,
8467
8670
  workbench_name,
@@ -8473,29 +8676,36 @@ class OTCS:
8473
8676
  )
8474
8677
  if not response:
8475
8678
  self.logger.error(
8476
- "Failed to unpack the transport package -> '%s'",
8679
+ "Failed to unpack the transport package -> '%s'!",
8477
8680
  package_name,
8478
8681
  )
8479
8682
  return None
8480
8683
  self.logger.debug(
8481
- "Successfully unpackaged to workbench -> '%s' (%s)",
8684
+ "Successfully unpackaged to workbench -> '%s' (%s).",
8482
8685
  workbench_name,
8483
8686
  str(workbench_id),
8484
8687
  )
8485
8688
 
8486
8689
  # Step 4: Deploy Workbench
8487
8690
  self.logger.debug(
8488
- "Deploy workbench -> '%s' (%s)",
8691
+ "Deploy workbench -> '%s' (%s)...",
8489
8692
  workbench_name,
8490
8693
  str(workbench_id),
8491
8694
  )
8492
- response = self.deploy_workbench(workbench_id=workbench_id)
8493
- if not response:
8494
- self.logger.error("Failed to deploy workbench -> '%s' (%s)", workbench_name, str(workbench_id))
8695
+ response, errors = self.deploy_workbench(workbench_id=workbench_id)
8696
+ if not response or errors > 0:
8697
+ self.logger.error(
8698
+ "Failed to deploy workbench -> '%s' (%s)!%s",
8699
+ workbench_name,
8700
+ str(workbench_id),
8701
+ " {} error{} occured during deployment.".format(errors, "s" if errors > 1 else "")
8702
+ if errors > 0
8703
+ else "",
8704
+ )
8495
8705
  return None
8496
8706
 
8497
8707
  self.logger.debug(
8498
- "Successfully deployed workbench -> '%s' (%s)",
8708
+ "Successfully deployed workbench -> '%s' (%s).",
8499
8709
  workbench_name,
8500
8710
  str(workbench_id),
8501
8711
  )
@@ -8589,7 +8799,7 @@ class OTCS:
8589
8799
  )
8590
8800
  continue
8591
8801
  self.logger.debug(
8592
- "Replace -> %s with -> %s in Transport package -> %s",
8802
+ "Replace -> %s with -> %s in transport package -> %s",
8593
8803
  replacement["placeholder"],
8594
8804
  replacement["value"],
8595
8805
  zip_file_folder,
@@ -8606,21 +8816,21 @@ class OTCS:
8606
8816
  )
8607
8817
  if found:
8608
8818
  self.logger.debug(
8609
- "Replacement -> %s has been completed successfully for Transport package -> %s",
8819
+ "Replacement -> %s has been completed successfully for transport package -> '%s'.",
8610
8820
  replacement,
8611
8821
  zip_file_folder,
8612
8822
  )
8613
8823
  modified = True
8614
8824
  else:
8615
8825
  self.logger.warning(
8616
- "Replacement -> %s not found in Transport package -> %s",
8826
+ "Replacement -> %s not found in transport package -> '%s'!",
8617
8827
  replacement,
8618
8828
  zip_file_folder,
8619
8829
  )
8620
8830
 
8621
8831
  if not modified:
8622
8832
  self.logger.warning(
8623
- "None of the specified replacements have been found in Transport package -> %s. No need to create a new transport package.",
8833
+ "None of the specified replacements have been found in transport package -> %s. No need to create a new transport package.",
8624
8834
  zip_file_folder,
8625
8835
  )
8626
8836
  return False
@@ -8628,7 +8838,7 @@ class OTCS:
8628
8838
  # Create the new zip file and add all files from the directory to it
8629
8839
  new_zip_file_path = os.path.dirname(zip_file_path) + "/new_" + os.path.basename(zip_file_path)
8630
8840
  self.logger.debug(
8631
- "Content of transport -> '%s' has been modified - repacking to new zip file -> %s",
8841
+ "Content of transport -> '%s' has been modified - repacking to new zip file -> '%s'...",
8632
8842
  zip_file_folder,
8633
8843
  new_zip_file_path,
8634
8844
  )
@@ -8645,13 +8855,13 @@ class OTCS:
8645
8855
  zip_ref.close()
8646
8856
  old_zip_file_path = os.path.dirname(zip_file_path) + "/old_" + os.path.basename(zip_file_path)
8647
8857
  self.logger.debug(
8648
- "Rename orginal transport zip file -> '%s' to -> '%s'",
8858
+ "Rename orginal transport zip file -> '%s' to -> '%s'...",
8649
8859
  zip_file_path,
8650
8860
  old_zip_file_path,
8651
8861
  )
8652
8862
  os.rename(zip_file_path, old_zip_file_path)
8653
8863
  self.logger.debug(
8654
- "Rename new transport zip file -> '%s' to -> '%s'",
8864
+ "Rename new transport zip file -> '%s' to -> '%s'...",
8655
8865
  new_zip_file_path,
8656
8866
  zip_file_path,
8657
8867
  )
@@ -8684,7 +8894,7 @@ class OTCS:
8684
8894
  """
8685
8895
 
8686
8896
  if not os.path.isfile(zip_file_path):
8687
- self.logger.error("Zip file -> '%s' not found.", zip_file_path)
8897
+ self.logger.error("Zip file -> '%s' not found!", zip_file_path)
8688
8898
  return False
8689
8899
 
8690
8900
  # Extract the zip file to a temporary directory
@@ -8696,7 +8906,7 @@ class OTCS:
8696
8906
  for extraction in extractions:
8697
8907
  if "xpath" not in extraction:
8698
8908
  self.logger.error(
8699
- "Extraction needs an XPath but it is not specified. Skipping...",
8909
+ "Extraction needs an xpath but it is not specified! Skipping...",
8700
8910
  )
8701
8911
  continue
8702
8912
  # Check if the extraction is explicitly disabled:
@@ -8709,7 +8919,7 @@ class OTCS:
8709
8919
 
8710
8920
  xpath = extraction["xpath"]
8711
8921
  self.logger.debug(
8712
- "Using xpath -> %s to extract the data",
8922
+ "Using xpath -> %s to extract the data.",
8713
8923
  xpath,
8714
8924
  )
8715
8925
 
@@ -8721,7 +8931,7 @@ class OTCS:
8721
8931
  )
8722
8932
  if extracted_data:
8723
8933
  self.logger.debug(
8724
- "Extraction with XPath -> %s has been successfully completed for Transport package -> %s",
8934
+ "Extraction with xpath -> %s has been successfully completed for transport package -> '%s'.",
8725
8935
  xpath,
8726
8936
  zip_file_folder,
8727
8937
  )
@@ -8729,7 +8939,7 @@ class OTCS:
8729
8939
  extraction["data"] = extracted_data
8730
8940
  else:
8731
8941
  self.logger.warning(
8732
- "Extraction with XPath -> %s has not delivered any data for Transport package -> %s",
8942
+ "Extraction with xpath -> %s has not delivered any data for transport package -> '%s'!",
8733
8943
  xpath,
8734
8944
  zip_file_folder,
8735
8945
  )
@@ -8752,6 +8962,36 @@ class OTCS:
8752
8962
  Business Object Types information (for all external systems)
8753
8963
  or None if the request fails.
8754
8964
 
8965
+ Example:
8966
+ {
8967
+ 'links': {
8968
+ 'data': {
8969
+ 'self': {
8970
+ 'body': '',
8971
+ 'content_type': '',
8972
+ 'href': '/api/v2/businessobjecttypes',
8973
+ 'method': 'GET',
8974
+ 'name': ''
8975
+ }
8976
+ }
8977
+ },
8978
+ 'results': [
8979
+ {
8980
+ 'data': {
8981
+ 'properties': {
8982
+ 'bo_type': 'account',
8983
+ 'bo_type_id': 54,
8984
+ 'bo_type_name': 'gw.account',
8985
+ 'ext_system_id': 'Guidewire Policy Center',
8986
+ 'is_default_Search': True,
8987
+ 'workspace_type_id': 33
8988
+ }
8989
+ }
8990
+ },
8991
+ ...
8992
+ ]
8993
+ }
8994
+
8755
8995
  """
8756
8996
 
8757
8997
  request_url = self.config()["businessObjectTypesUrl"]
@@ -9247,7 +9487,7 @@ class OTCS:
9247
9487
  expand_workspace_info: bool = True,
9248
9488
  expand_templates: bool = True,
9249
9489
  ) -> dict | None:
9250
- """Get all workspace types configured in Extended ECM.
9490
+ """Get all workspace types configured in OTCS.
9251
9491
 
9252
9492
  This REST API is very limited. It does not return all workspace type properties
9253
9493
  you can see in OTCS business admin page.
@@ -9381,6 +9621,7 @@ class OTCS:
9381
9621
 
9382
9622
  # end method definition
9383
9623
 
9624
+ @cache
9384
9625
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_type")
9385
9626
  def get_workspace_type(
9386
9627
  self,
@@ -9401,8 +9642,11 @@ class OTCS:
9401
9642
  Workspace Types or None if the request fails.
9402
9643
 
9403
9644
  Example:
9404
- ```json
9405
- ```
9645
+ {
9646
+ 'icon_url': '/cssupport/otsapxecm/wksp_contract_cust.png',
9647
+ 'is_policies_enabled': False,
9648
+ 'workspace_type': 'Sales Contract'
9649
+ }
9406
9650
 
9407
9651
  """
9408
9652
 
@@ -9424,11 +9668,11 @@ class OTCS:
9424
9668
 
9425
9669
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_type_name")
9426
9670
  def get_workspace_type_name(self, type_id: int) -> str | None:
9427
- """Get the name of a workspace type based on the provided type ID.
9671
+ """Get the name of a workspace type based on the provided workspace type ID.
9428
9672
 
9429
- The name is taken from a OTCS global variable if recorded there.
9673
+ The name is taken from a OTCS object variable self._workspace_type_lookup if recorded there.
9430
9674
  If not yet derived it is determined via the REST API and then stored
9431
- in the global list.
9675
+ in self._workspace_type_lookup (as a lookup cache).
9432
9676
 
9433
9677
  Args:
9434
9678
  type_id (int):
@@ -9439,6 +9683,10 @@ class OTCS:
9439
9683
  The name of the workspace type. Or None if the type ID
9440
9684
  was ot found.
9441
9685
 
9686
+ Side effects:
9687
+ Caches the workspace type name in self._workspace_type_lookup
9688
+ for future calls.
9689
+
9442
9690
  """
9443
9691
 
9444
9692
  workspace_type = self._workspace_type_lookup.get(type_id)
@@ -9448,6 +9696,7 @@ class OTCS:
9448
9696
  workspace_type = self.get_workspace_type(type_id=type_id)
9449
9697
  type_name = workspace_type.get("workspace_type")
9450
9698
  if type_name:
9699
+ # Update the lookup cache:
9451
9700
  self._workspace_type_lookup[type_id] = {"location": None, "name": type_name}
9452
9701
  return type_name
9453
9702
 
@@ -9455,6 +9704,43 @@ class OTCS:
9455
9704
 
9456
9705
  # end method definition
9457
9706
 
9707
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_type_by_name")
9708
+ def get_workspace_type_names(self, lower_case: bool = False, renew: bool = False) -> list[str] | None:
9709
+ """Get a list of all workspace type names.
9710
+
9711
+ Args:
9712
+ lower_case (bool):
9713
+ Whether to return the names in lower case.
9714
+ renew (bool):
9715
+ Whether to renew the cached workspace type names.
9716
+
9717
+ Returns:
9718
+ list[str] | None:
9719
+ List of workspace type names or None if the request fails.
9720
+
9721
+ Side effects:
9722
+ Caches the workspace type names in self._workspace_type_names
9723
+ for future calls.
9724
+
9725
+ """
9726
+
9727
+ if self._workspace_type_names and not renew:
9728
+ return self._workspace_type_names
9729
+
9730
+ workspace_types = self.get_workspace_types_iterator()
9731
+ workspace_type_names = [
9732
+ self.get_result_value(response=workspace_type, key="wksp_type_name") for workspace_type in workspace_types
9733
+ ]
9734
+ if lower_case:
9735
+ workspace_type_names = [name.lower() for name in workspace_type_names]
9736
+
9737
+ # Update the cache:
9738
+ self._workspace_type_names = workspace_type_names
9739
+
9740
+ return workspace_type_names
9741
+
9742
+ # end method definition
9743
+
9458
9744
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_templates")
9459
9745
  def get_workspace_templates(
9460
9746
  self, type_id: int | None = None, type_name: str | None = None
@@ -9580,7 +9866,7 @@ class OTCS:
9580
9866
  def get_workspace(
9581
9867
  self,
9582
9868
  node_id: int,
9583
- fields: (str | list) = "properties", # per default we just get the most important information
9869
+ fields: str | list = "properties", # per default we just get the most important information
9584
9870
  metadata: bool = False,
9585
9871
  ) -> dict | None:
9586
9872
  """Get a workspace based on the node ID.
@@ -9594,11 +9880,11 @@ class OTCS:
9594
9880
  Possible fields include:
9595
9881
  - "properties" (can be further restricted by specifying sub-fields,
9596
9882
  e.g., "properties{id,name,parent_id,description}")
9597
- - "categories"
9598
- - "versions" (can be further restricted by specifying ".element(0)" to
9599
- retrieve only the latest version)
9600
- - "permissions" (can be further restricted by specifying ".limit(5)" to
9601
- retrieve only the first 5 permissions)
9883
+ - "business_properties" (all the information for the business object and the external system)
9884
+ - "categories" (the category data of the workspace item)
9885
+ - "workspace_references" (a list with the references to business objects in external systems)
9886
+ - "display_urls" (a list with the URLs to external business systems)
9887
+ - "wksp_info" (currently just the icon information of the workspace)
9602
9888
  This parameter can be a string to select one field group or a list of
9603
9889
  strings to select multiple field groups.
9604
9890
  Defaults to "properties".
@@ -9692,10 +9978,31 @@ class OTCS:
9692
9978
  'wksp_type_name': 'Vendor',
9693
9979
  'xgov_workspace_type': ''
9694
9980
  }
9981
+ 'display_urls': [
9982
+ {
9983
+ 'business_object_type': 'LFA1',
9984
+ 'business_object_type_id': 30,
9985
+ 'business_object_type_name': 'Customer',
9986
+ 'displayUrl': '/sap/bc/gui/sap/its/webgui?~logingroup=SPACE&~transaction=%2fOTX%2fRM_WSC_START_BO+KEY%3dpc%3AS3XljqcFfD0pakDIjUKul%3bOBJTYPE%3daccount&~OkCode=ONLI',
9987
+ 'external_system_id': 'TE1',
9988
+ 'external_system_name': 'SAP S/4HANA'
9989
+ }
9990
+ ]
9695
9991
  'wksp_info':
9696
9992
  {
9697
9993
  'wksp_type_icon': '/appimg/ot_bws/icons/16634%2Esvg?v=161194_13949'
9698
9994
  }
9995
+ 'workspace_references': [
9996
+ {
9997
+ 'business_object_id': '0000010020',
9998
+ 'business_object_type': 'LFA1',
9999
+ 'business_object_type_id': 30,
10000
+ 'external_system_id': 'TE1',
10001
+ 'has_default_display': True,
10002
+ 'has_default_search': True,
10003
+ 'workspace_type_id': 37
10004
+ }
10005
+ ]
9699
10006
  },
9700
10007
  'metadata': {...},
9701
10008
  'metadata_order': {
@@ -9703,20 +10010,6 @@ class OTCS:
9703
10010
  }
9704
10011
  }
9705
10012
  ],
9706
- 'wksp_info': {
9707
- 'wksp_type_icon': '/appimg/ot_bws/icons/24643%2Esvg?v=161696_1252'
9708
- }
9709
- 'workspace_references': [
9710
- {
9711
- 'business_object_id': '0000010020',
9712
- 'business_object_type': 'LFA1',
9713
- 'business_object_type_id': 30,
9714
- 'external_system_id': 'TE1',
9715
- 'has_default_display': True,
9716
- 'has_default_search': True,
9717
- 'workspace_type_id': 37
9718
- }
9719
- ]
9720
10013
  }
9721
10014
  ```
9722
10015
 
@@ -9762,6 +10055,8 @@ class OTCS:
9762
10055
  expanded_view: bool = True,
9763
10056
  page: int | None = None,
9764
10057
  limit: int | None = None,
10058
+ fields: str | list = "properties", # per default we just get the most important information
10059
+ metadata: bool = False,
9765
10060
  ) -> dict | None:
9766
10061
  """Get all workspace instances of a given type.
9767
10062
 
@@ -9794,6 +10089,25 @@ class OTCS:
9794
10089
  The page to be returned (if more workspace instances exist
9795
10090
  than given by the page limit).
9796
10091
  The default is None.
10092
+ fields (str | list, optional):
10093
+ Which fields to retrieve. This can have a significant
10094
+ impact on performance.
10095
+ Possible fields include:
10096
+ - "properties" (can be further restricted by specifying sub-fields,
10097
+ e.g., "properties{id,name,parent_id,description}")
10098
+ - "categories"
10099
+ - "versions" (can be further restricted by specifying ".element(0)" to
10100
+ retrieve only the latest version)
10101
+ - "permissions" (can be further restricted by specifying ".limit(5)" to
10102
+ retrieve only the first 5 permissions)
10103
+ This parameter can be a string to select one field group or a list of
10104
+ strings to select multiple field groups.
10105
+ Defaults to "properties".
10106
+ metadata (bool, optional):
10107
+ Whether to return metadata (data type, field length, min/max values,...)
10108
+ about the data.
10109
+ Metadata will be returned under `results.metadata`, `metadata_map`,
10110
+ or `metadata_order`.
9797
10111
 
9798
10112
  Returns:
9799
10113
  dict | None:
@@ -9809,6 +10123,8 @@ class OTCS:
9809
10123
  expanded_view=expanded_view,
9810
10124
  page=page,
9811
10125
  limit=limit,
10126
+ fields=fields,
10127
+ metadata=metadata,
9812
10128
  )
9813
10129
 
9814
10130
  # end method definition
@@ -9819,6 +10135,9 @@ class OTCS:
9819
10135
  type_id: int | None = None,
9820
10136
  expanded_view: bool = True,
9821
10137
  page_size: int = 100,
10138
+ limit: int | None = None,
10139
+ fields: str | list = "properties", # per default we just get the most important information
10140
+ metadata: bool = False,
9822
10141
  ) -> iter:
9823
10142
  """Get an iterator object to traverse all workspace instances of a workspace type.
9824
10143
 
@@ -9849,6 +10168,29 @@ class OTCS:
9849
10168
  page_size (int | None, optional):
9850
10169
  The maximum number of workspace instances that should be delivered in one page.
9851
10170
  The default is 100. If None is given then the internal OTCS limit seems to be 500.
10171
+ limit (int | None = None), optional):
10172
+ The maximum number of workspaces to return in total.
10173
+ If None (default) all workspaces are returned.
10174
+ If a number is provided only up to this number of results is returned.
10175
+ fields (str | list, optional):
10176
+ Which fields to retrieve. This can have a significant
10177
+ impact on performance.
10178
+ Possible fields include:
10179
+ - "properties" (can be further restricted by specifying sub-fields,
10180
+ e.g., "properties{id,name,parent_id,description}")
10181
+ - "categories"
10182
+ - "versions" (can be further restricted by specifying ".element(0)" to
10183
+ retrieve only the latest version)
10184
+ - "permissions" (can be further restricted by specifying ".limit(5)" to
10185
+ retrieve only the first 5 permissions)
10186
+ This parameter can be a string to select one field group or a list of
10187
+ strings to select multiple field groups.
10188
+ Defaults to "properties".
10189
+ metadata (bool, optional):
10190
+ Whether to return metadata (data type, field length, min/max values,...)
10191
+ about the data.
10192
+ Metadata will be returned under `results.metadata`, `metadata_map`,
10193
+ or `metadata_order`.
9852
10194
 
9853
10195
  Returns:
9854
10196
  iter:
@@ -9863,8 +10205,10 @@ class OTCS:
9863
10205
  type_id=type_id,
9864
10206
  name="",
9865
10207
  expanded_view=expanded_view,
9866
- page=1,
9867
10208
  limit=1,
10209
+ page=1,
10210
+ fields=fields,
10211
+ metadata=metadata,
9868
10212
  )
9869
10213
  if not response or "results" not in response:
9870
10214
  # Don't return None! Plain return is what we need for iterators.
@@ -9873,9 +10217,12 @@ class OTCS:
9873
10217
  return
9874
10218
 
9875
10219
  number_of_instances = response["paging"]["total_count"]
10220
+ if limit and number_of_instances > limit:
10221
+ number_of_instances = limit
10222
+
9876
10223
  if not number_of_instances:
9877
10224
  self.logger.debug(
9878
- "Workspace type -> %s does not have instances! Cannot iterate over instances.",
10225
+ "Workspace type -> '%s' does not have instances! Cannot iterate over instances.",
9879
10226
  type_name if type_name else str(type_id),
9880
10227
  )
9881
10228
  # Don't return None! Plain return is what we need for iterators.
@@ -9897,7 +10244,9 @@ class OTCS:
9897
10244
  name="",
9898
10245
  expanded_view=expanded_view,
9899
10246
  page=page,
9900
- limit=page_size,
10247
+ limit=page_size if limit is None else limit,
10248
+ fields=fields,
10249
+ metadata=metadata,
9901
10250
  )
9902
10251
  if not response or not response.get("results", None):
9903
10252
  self.logger.warning(
@@ -9923,9 +10272,9 @@ class OTCS:
9923
10272
  expanded_view: bool = True,
9924
10273
  limit: int | None = None,
9925
10274
  page: int | None = None,
9926
- fields: (str | list) = "properties", # per default we just get the most important information
10275
+ fields: str | list = "properties", # per default we just get the most important information
9927
10276
  metadata: bool = False,
9928
- timeout: int = REQUEST_TIMEOUT,
10277
+ timeout: float = REQUEST_TIMEOUT,
9929
10278
  ) -> dict | None:
9930
10279
  """Lookup workspaces based on workspace type and workspace name.
9931
10280
 
@@ -9981,7 +10330,7 @@ class OTCS:
9981
10330
  about the data.
9982
10331
  Metadata will be returned under `results.metadata`, `metadata_map`,
9983
10332
  or `metadata_order`.
9984
- timeout (int, optional):
10333
+ timeout (float, optional):
9985
10334
  Specific timeout for the request in seconds. The default is the standard
9986
10335
  timeout value REQUEST_TIMEOUT used by the OTCS module.
9987
10336
 
@@ -10191,7 +10540,7 @@ class OTCS:
10191
10540
  external_system_name: str,
10192
10541
  business_object_type: str,
10193
10542
  business_object_id: str,
10194
- return_workspace_metadata: bool = False,
10543
+ metadata: bool = False,
10195
10544
  show_error: bool = False,
10196
10545
  ) -> dict | None:
10197
10546
  """Get a workspace based on the business object of an external system.
@@ -10203,7 +10552,7 @@ class OTCS:
10203
10552
  Type of the Business object, e.g. KNA1 for SAP customers
10204
10553
  business_object_id (str):
10205
10554
  ID of the business object in the external system
10206
- return_workspace_metadata (bool, optional):
10555
+ metadata (bool, optional):
10207
10556
  Whether or not workspace metadata (categories) should be returned.
10208
10557
  Default is False.
10209
10558
  show_error (bool, optional):
@@ -10285,7 +10634,7 @@ class OTCS:
10285
10634
  + "/boids/"
10286
10635
  + business_object_id
10287
10636
  )
10288
- if return_workspace_metadata:
10637
+ if metadata:
10289
10638
  request_url += "?metadata"
10290
10639
 
10291
10640
  request_header = self.request_form_header()
@@ -10319,7 +10668,7 @@ class OTCS:
10319
10668
  # end method definition
10320
10669
 
10321
10670
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="lookup_workspace")
10322
- def lookup_workspace(
10671
+ def lookup_workspaces(
10323
10672
  self,
10324
10673
  type_name: str,
10325
10674
  category: str,
@@ -10327,11 +10676,12 @@ class OTCS:
10327
10676
  value: str,
10328
10677
  attribute_set: str | None = None,
10329
10678
  ) -> dict | None:
10330
- """Lookup the workspace that has a specified value in a category attribute.
10679
+ """Lookup workspaces that have a specified value in a category attribute.
10331
10680
 
10332
10681
  Args:
10333
10682
  type_name (str):
10334
- The name of the workspace type.
10683
+ The name of the workspace type. This is required to determine
10684
+ the parent folder in which the workspaces of this type reside.
10335
10685
  category (str):
10336
10686
  The name of the category.
10337
10687
  attribute (str):
@@ -10339,7 +10689,8 @@ class OTCS:
10339
10689
  value (str):
10340
10690
  The lookup value that is matched agains the node attribute value.
10341
10691
  attribute_set (str | None, optional):
10342
- The name of the attribute set
10692
+ The name of the attribute set. If None (default) the attribute to lookup
10693
+ is supposed to be a top-level attribute.
10343
10694
 
10344
10695
  Returns:
10345
10696
  dict | None:
@@ -10358,12 +10709,44 @@ class OTCS:
10358
10709
  )
10359
10710
  return None
10360
10711
 
10361
- return self.lookup_node(
10712
+ return self.lookup_nodes(
10362
10713
  parent_node_id=parent_id, category=category, attribute=attribute, value=value, attribute_set=attribute_set
10363
10714
  )
10364
10715
 
10365
10716
  # end method definition
10366
10717
 
10718
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace")
10719
+ def get_workspace_references(
10720
+ self,
10721
+ node_id: int,
10722
+ ) -> list | None:
10723
+ """Get a workspace rewferences to business objects in external systems.
10724
+
10725
+ Args:
10726
+ node_id (int):
10727
+ The node ID of the workspace to retrieve.
10728
+
10729
+ Returns:
10730
+ list | None:
10731
+ A List of references to business objects in external systems.
10732
+
10733
+ """
10734
+
10735
+ response = self.get_workspace(node_id=node_id, fields="workspace_references")
10736
+
10737
+ results = response.get("results")
10738
+ if not results:
10739
+ return None
10740
+ data = results.get("data")
10741
+ if not data:
10742
+ return None
10743
+
10744
+ workspace_references: list = data.get("workspace_references")
10745
+
10746
+ return workspace_references
10747
+
10748
+ # end method definition
10749
+
10367
10750
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="set_workspace_reference")
10368
10751
  def set_workspace_reference(
10369
10752
  self,
@@ -10422,13 +10805,88 @@ class OTCS:
10422
10805
  headers=request_header,
10423
10806
  data=workspace_put_data,
10424
10807
  timeout=None,
10425
- warning_message="Cannot update reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10808
+ warning_message="Cannot update reference for workspace ID -> {} with business object connection -> ('{}', '{}', {})".format(
10809
+ workspace_id,
10810
+ external_system_id,
10811
+ bo_type,
10812
+ bo_id,
10813
+ ),
10814
+ failure_message="Failed to update reference for workspace ID -> {} with business object connection -> ('{}', '{}', {})".format(
10815
+ workspace_id,
10816
+ external_system_id,
10817
+ bo_type,
10818
+ bo_id,
10819
+ ),
10820
+ show_error=show_error,
10821
+ )
10822
+
10823
+ # end method definition
10824
+
10825
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="delete_workspace_reference")
10826
+ def delete_workspace_reference(
10827
+ self,
10828
+ workspace_id: int,
10829
+ external_system_id: str | None = None,
10830
+ bo_type: str | None = None,
10831
+ bo_id: str | None = None,
10832
+ show_error: bool = True,
10833
+ ) -> dict | None:
10834
+ """Delete reference of workspace to a business object in an external system.
10835
+
10836
+ Args:
10837
+ workspace_id (int):
10838
+ The ID of the workspace.
10839
+ external_system_id (str | None, optional):
10840
+ Identifier of the external system (None if no external system).
10841
+ bo_type (str | None, optional):
10842
+ Business object type (None if no external system)
10843
+ bo_id (str | None, optional):
10844
+ Business object identifier / key (None if no external system)
10845
+ show_error (bool, optional):
10846
+ Log an error if workspace cration fails. Otherwise log a warning.
10847
+
10848
+ Returns:
10849
+ Request response or None in case of an error.
10850
+
10851
+ """
10852
+
10853
+ request_url = self.config()["businessWorkspacesUrl"] + "/" + str(workspace_id) + "/workspacereferences"
10854
+ request_header = self.request_form_header()
10855
+
10856
+ if not external_system_id or not bo_type or not bo_id:
10857
+ self.logger.error(
10858
+ "Cannot update workspace reference - required Business Object information is missing!",
10859
+ )
10860
+ return None
10861
+
10862
+ self.logger.debug(
10863
+ "Delete workspace reference of workspace ID -> %d with business object connection -> (%s, %s, %s); calling -> %s",
10864
+ workspace_id,
10865
+ external_system_id,
10866
+ bo_type,
10867
+ bo_id,
10868
+ request_url,
10869
+ )
10870
+
10871
+ workspace_put_data = {
10872
+ "ext_system_id": external_system_id,
10873
+ "bo_type": bo_type,
10874
+ "bo_id": bo_id,
10875
+ }
10876
+
10877
+ return self.do_request(
10878
+ url=request_url,
10879
+ method="DELETE",
10880
+ headers=request_header,
10881
+ data=workspace_put_data,
10882
+ timeout=None,
10883
+ warning_message="Cannot delete reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10426
10884
  workspace_id,
10427
10885
  external_system_id,
10428
10886
  bo_type,
10429
10887
  bo_id,
10430
10888
  ),
10431
- failure_message="Failed to update reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10889
+ failure_message="Failed to delete reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10432
10890
  workspace_id,
10433
10891
  external_system_id,
10434
10892
  bo_type,
@@ -10475,26 +10933,26 @@ class OTCS:
10475
10933
  A description of the new workspace.
10476
10934
  workspace_type (int):
10477
10935
  Type ID of the workspace, indicating its category or function.
10478
- category_data (dict, optional):
10936
+ category_data (dict | None, optional):
10479
10937
  Category and attribute data for the workspace.
10480
- classifications (list):
10938
+ classifications (list | None, optional):
10481
10939
  List of classification item IDs to apply to the new item.
10482
- external_system_id (str, optional):
10940
+ external_system_id (str | None, optional):
10483
10941
  External system identifier if linking the workspace to an external system.
10484
- bo_type (str, optional):
10942
+ bo_type (str | None, optional):
10485
10943
  Business object type, used if linking to an external system.
10486
- bo_id (str, optional):
10944
+ bo_id (str | None, optional):
10487
10945
  Business object identifier or key, used if linking to an external system.
10488
- parent_id (int, optional):
10946
+ parent_id (int | None, optional):
10489
10947
  ID of the parent workspace, required in special cases such as
10490
10948
  sub-workspaces or location ambiguity.
10491
- ibo_workspace_id (int, optional):
10949
+ ibo_workspace_id (int | None, optional):
10492
10950
  ID of an existing workspace that is already linked to an external system.
10493
10951
  Allows connecting multiple business objects (IBO).
10494
- external_create_date (str, optional):
10952
+ external_create_date (str | None, optional):
10495
10953
  Date of creation in the external system (format: YYYY-MM-DD).
10496
10954
  None is the default.
10497
- external_modify_date (str, optional):
10955
+ external_modify_date (str | None, optional):
10498
10956
  Date of last modification in the external system (format: YYYY-MM-DD).
10499
10957
  None is the default.
10500
10958
  show_error (bool, optional):
@@ -10670,16 +11128,16 @@ class OTCS:
10670
11128
  category_data (dict | None, optional):
10671
11129
  Category and attribute data.
10672
11130
  Default is None (attributes remain unchanged).
10673
- external_system_id (str, optional):
11131
+ external_system_id (str | None, optional):
10674
11132
  Identifier of the external system (None if no external system)
10675
- bo_type (str, optional):
11133
+ bo_type (str | None, optional):
10676
11134
  Business object type (None if no external system)
10677
- bo_id (str, optional):
11135
+ bo_id (str | None, optional):
10678
11136
  Business object identifier / key (None if no external system)
10679
- external_create_date (str, optional):
11137
+ external_create_date (str | None, optional):
10680
11138
  Date of creation in the external system
10681
11139
  (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
10682
- external_modify_date (str, optional):
11140
+ external_modify_date (str | None, optional):
10683
11141
  Date of last modification in the external system
10684
11142
  (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
10685
11143
  show_error (bool, optional):
@@ -10787,12 +11245,13 @@ class OTCS:
10787
11245
  def get_workspace_relationships(
10788
11246
  self,
10789
11247
  workspace_id: int,
10790
- relationship_type: str | list | None = None,
11248
+ relationship_type: str | list = "child",
10791
11249
  related_workspace_name: str | None = None,
10792
11250
  related_workspace_type_id: int | list | None = None,
10793
11251
  limit: int | None = None,
10794
11252
  page: int | None = None,
10795
- fields: (str | list) = "properties", # per default we just get the most important information
11253
+ fields: str | list = "properties", # per default we just get the most important information
11254
+ metadata: bool = False,
10796
11255
  ) -> dict | None:
10797
11256
  """Get the Workspace relationships to other workspaces.
10798
11257
 
@@ -10804,7 +11263,7 @@ class OTCS:
10804
11263
  workspace_id (int):
10805
11264
  The ID of the workspace.
10806
11265
  relationship_type (str | list, optional):
10807
- Either "parent" or "child" (or None = unspecified which is the default).
11266
+ Either "parent" or "child" ("child" is the default).
10808
11267
  If both ("child" and "parent") are requested then use a
10809
11268
  list like ["child", "parent"].
10810
11269
  related_workspace_name (str | None, optional):
@@ -10834,6 +11293,9 @@ class OTCS:
10834
11293
  This parameter can be a string to select one field group or a list of
10835
11294
  strings to select multiple field groups.
10836
11295
  Defaults to "properties".
11296
+ metadata (bool, optional):
11297
+ Whether or not workspace metadata (categories) should be returned.
11298
+ Default is False.
10837
11299
 
10838
11300
  Returns:
10839
11301
  dict | None:
@@ -10928,7 +11390,13 @@ class OTCS:
10928
11390
  if isinstance(relationship_type, str):
10929
11391
  query["where_relationtype"] = relationship_type
10930
11392
  elif isinstance(relationship_type, list):
10931
- query["where_rel_types"] = relationship_type
11393
+ if any(rt not in ["parent", "child"] for rt in relationship_type):
11394
+ self.logger.error(
11395
+ "Illegal relationship type for related workspace type! Must be either 'parent' or 'child'. -> %s",
11396
+ relationship_type,
11397
+ )
11398
+ return None
11399
+ query["where_rel_types"] = "{'" + ("','").join(relationship_type) + "'}"
10932
11400
  else:
10933
11401
  self.logger.error(
10934
11402
  "Illegal relationship type for related workspace type!",
@@ -10956,6 +11424,8 @@ class OTCS:
10956
11424
 
10957
11425
  encoded_query = urllib.parse.urlencode(query=query, doseq=False)
10958
11426
  request_url += "?{}".format(encoded_query)
11427
+ if metadata:
11428
+ request_url += "&metadata"
10959
11429
 
10960
11430
  request_header = self.request_form_header()
10961
11431
 
@@ -10980,11 +11450,13 @@ class OTCS:
10980
11450
  def get_workspace_relationships_iterator(
10981
11451
  self,
10982
11452
  workspace_id: int,
10983
- relationship_type: str | list | None = None,
11453
+ relationship_type: str | list = "child",
10984
11454
  related_workspace_name: str | None = None,
10985
11455
  related_workspace_type_id: int | list | None = None,
10986
- fields: (str | list) = "properties", # per default we just get the most important information
11456
+ fields: str | list = "properties", # per default we just get the most important information
10987
11457
  page_size: int = 100,
11458
+ limit: int | None = None,
11459
+ metadata: bool = False,
10988
11460
  ) -> iter:
10989
11461
  """Get an iterator object to traverse all related workspaces for a workspace.
10990
11462
 
@@ -11005,7 +11477,7 @@ class OTCS:
11005
11477
  workspace_id (int):
11006
11478
  The ID of the workspace.
11007
11479
  relationship_type (str | list, optional):
11008
- Either "parent" or "child" (or None = unspecified which is the default).
11480
+ Either "parent" or "child" ("child" is the default).
11009
11481
  If both ("child" and "parent") are requested then use a
11010
11482
  list like ["child", "parent"].
11011
11483
  related_workspace_name (str | None, optional):
@@ -11015,14 +11487,12 @@ class OTCS:
11015
11487
  fields (str | list, optional):
11016
11488
  Which fields to retrieve. This can have a significant
11017
11489
  impact on performance.
11018
- Possible fields include:
11490
+ Possible fields include (NOTE: "categories" is not supported in this method!!):
11019
11491
  - "properties" (can be further restricted by specifying sub-fields,
11020
11492
  e.g., "properties{id,name,parent_id,description}")
11021
- - "categories"
11022
- - "versions" (can be further restricted by specifying ".element(0)" to
11023
- retrieve only the latest version)
11024
- - "permissions" (can be further restricted by specifying ".limit(5)" to
11025
- retrieve only the first 5 permissions)
11493
+ - "business_properties" (all the information for the business object and the external system)
11494
+ - "workspace_references" (a list with the references to business objects in external systems)
11495
+ - "wksp_info" (currently just the icon information of the workspace)
11026
11496
  This parameter can be a string to select one field group or a list of
11027
11497
  strings to select multiple field groups.
11028
11498
  Defaults to "properties".
@@ -11032,6 +11502,14 @@ class OTCS:
11032
11502
  The default is None, in this case the internal OTCS limit seems
11033
11503
  to be 500.
11034
11504
  This is basically the chunk size for the iterator.
11505
+ limit (int | None = None), optional):
11506
+ The maximum number of workspaces to return in total.
11507
+ If None (default) all workspaces are returned.
11508
+ If a number is provided only up to this number of results is returned.
11509
+ metadata (bool, optional):
11510
+ Whether or not workspace metadata should be returned. These are
11511
+ the system level metadata - not the categories of the workspace!
11512
+ Default is False.
11035
11513
 
11036
11514
  Returns:
11037
11515
  iter:
@@ -11049,6 +11527,7 @@ class OTCS:
11049
11527
  limit=1,
11050
11528
  page=1,
11051
11529
  fields=fields,
11530
+ metadata=metadata,
11052
11531
  )
11053
11532
  if not response or "results" not in response:
11054
11533
  # Don't return None! Plain return is what we need for iterators.
@@ -11057,6 +11536,9 @@ class OTCS:
11057
11536
  return
11058
11537
 
11059
11538
  number_of_related_workspaces = response["paging"]["total_count"]
11539
+ if limit and number_of_related_workspaces > limit:
11540
+ number_of_related_workspaces = limit
11541
+
11060
11542
  if not number_of_related_workspaces:
11061
11543
  self.logger.debug(
11062
11544
  "Workspace with node ID -> %d does not have related workspaces! Cannot iterate over related workspaces.",
@@ -11080,9 +11562,10 @@ class OTCS:
11080
11562
  relationship_type=relationship_type,
11081
11563
  related_workspace_name=related_workspace_name,
11082
11564
  related_workspace_type_id=related_workspace_type_id,
11083
- limit=page_size,
11565
+ limit=page_size if limit is None else limit,
11084
11566
  page=page,
11085
11567
  fields=fields,
11568
+ metadata=metadata,
11086
11569
  )
11087
11570
  if not response or not response.get("results", None):
11088
11571
  self.logger.warning(
@@ -11314,6 +11797,46 @@ class OTCS:
11314
11797
  dict | None:
11315
11798
  Workspace Role Membership or None if the request fails.
11316
11799
 
11800
+ Example:
11801
+ {
11802
+ 'links': {
11803
+ 'data': {
11804
+ 'self': {
11805
+ 'body': '',
11806
+ 'content_type': '',
11807
+ 'href': '/api/v2/businessworkspaces/80998/roles/81001/members',
11808
+ 'method': 'POST',
11809
+ 'name': ''
11810
+ }
11811
+ },
11812
+ 'results': {
11813
+ 'data': {
11814
+ 'properties': {
11815
+ 'birth_date': None,
11816
+ 'business_email': 'lwhite@terrarium.cloud',
11817
+ 'business_fax': None,
11818
+ 'business_phone': '+1 (345) 4626-333',
11819
+ 'cell_phone': None,
11820
+ 'deleted': False,
11821
+ 'display_language': None,
11822
+ 'display_name': 'Liz White',
11823
+ 'first_name': 'Liz',
11824
+ 'gender': None,
11825
+ 'group_id': 16178,
11826
+ 'group_name': 'Executive Leadership Team',
11827
+ 'home_address_1': None,
11828
+ 'home_address_2': None,
11829
+ 'home_fax': None,
11830
+ 'home_phone': None,
11831
+ 'id': 15520,
11832
+ 'initials': 'LW',
11833
+ 'last_name': 'White',
11834
+ ...
11835
+ }
11836
+ }
11837
+ }
11838
+ }
11839
+
11317
11840
  """
11318
11841
 
11319
11842
  self.logger.debug(
@@ -12022,7 +12545,7 @@ class OTCS:
12022
12545
 
12023
12546
  Args:
12024
12547
  parent_id (int):
12025
- The node the category should be applied to.
12548
+ The parent node of the new node to create.
12026
12549
  subtype (int, optional):
12027
12550
  The subtype of the new node. Default is document.
12028
12551
  category_ids (int | list[int], optional):
@@ -12030,7 +12553,7 @@ class OTCS:
12030
12553
 
12031
12554
  Returns:
12032
12555
  dict | None:
12033
- Workspace Create Form data or None if the request fails.
12556
+ Node create form data or None if the request fails.
12034
12557
 
12035
12558
  """
12036
12559
 
@@ -12065,6 +12588,151 @@ class OTCS:
12065
12588
 
12066
12589
  # end method definition
12067
12590
 
12591
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_node_category_update_form")
12592
+ def get_node_category_form(
12593
+ self,
12594
+ node_id: int,
12595
+ category_id: int | None = None,
12596
+ operation: str = "update",
12597
+ ) -> dict | None:
12598
+ """Get the node category update form.
12599
+
12600
+ Args:
12601
+ node_id (int):
12602
+ The ID of the node to update.
12603
+ category_id (int | None, optional):
12604
+ The ID of the category to update.
12605
+ operation (str, optional):
12606
+ The operation to perform. Default is "update". Other possible value is "create".
12607
+
12608
+ Returns:
12609
+ dict | None:
12610
+ Workspace Category Update Form data or None if the request fails.
12611
+
12612
+ Example:
12613
+ {
12614
+ 'forms': [
12615
+ {
12616
+ 'data': {
12617
+ '20581_1': {'metadata_token': ''},
12618
+ '20581_10': None,
12619
+ '20581_11': None,
12620
+ '20581_12': None,
12621
+ '20581_13': None,
12622
+ '20581_14': [
12623
+ {
12624
+ '20581_14_x_15': None,
12625
+ '20581_14_x_16': None,
12626
+ '20581_14_x_17': None,
12627
+ '20581_14_x_18': None,
12628
+ '20581_14_x_19': None,
12629
+ '20581_14_x_20': None,
12630
+ '20581_14_x_21': None,
12631
+ '20581_14_x_22': None
12632
+ }
12633
+ ],
12634
+ '20581_14_1': None,
12635
+ '20581_2': None,
12636
+ '20581_23': [
12637
+ {
12638
+ '20581_23_x_25': None,
12639
+ '20581_23_x_26': None,
12640
+ '20581_23_x_27': None,
12641
+ '20581_23_x_28': None,
12642
+ '20581_23_x_29': None,
12643
+ '20581_23_x_30': None,
12644
+ '20581_23_x_31': None,
12645
+ '20581_23_x_32': None,
12646
+ '20581_23_x_37': None
12647
+ }
12648
+ ],
12649
+ '20581_23_1': None,
12650
+ '20581_3': None,
12651
+ '20581_33': {
12652
+ '20581_33_1_34': None,
12653
+ '20581_33_1_35': None,
12654
+ '20581_33_1_36': None
12655
+ },
12656
+ '20581_4': None,
12657
+ '20581_5': None,
12658
+ '20581_6': None,
12659
+ '20581_7': None,
12660
+ '20581_8': None,
12661
+ '20581_9': None
12662
+ },
12663
+ 'options': {
12664
+ 'fields': {...},
12665
+ 'form': {...}
12666
+ },
12667
+ 'schema': {
12668
+ 'properties': {
12669
+ '20581_1': {
12670
+ 'readonly': True,
12671
+ 'required': False, 'type': 'object'},
12672
+ '20581_2': {
12673
+ 'maxLength': 20,
12674
+ 'multilingual': None,
12675
+ 'readonly': False,
12676
+ 'required': False,
12677
+ 'title': 'Order Number',
12678
+ 'type': 'string'
12679
+ },
12680
+ '20581_11': {
12681
+ 'maxLength': 25,
12682
+ 'multilingual': None,
12683
+ 'readonly': False,
12684
+ 'required': False,
12685
+ 'title': 'Order Type',
12686
+ 'type': 'string'
12687
+ },
12688
+ ... (more fields) ...
12689
+ },
12690
+ 'type': 'object'
12691
+ }
12692
+ }
12693
+ ]
12694
+ }
12695
+
12696
+ """
12697
+
12698
+ request_header = self.request_form_header()
12699
+
12700
+ # If no category ID is provided get the current category IDs of the node and take the first one.
12701
+ # TODO: we need to be more clever here if multiple categories are assigned to a node.
12702
+ if category_id is None:
12703
+ category_ids = self.get_node_category_ids(node_id=node_id)
12704
+ if not category_ids or not isinstance(category_ids, list):
12705
+ self.logger.error("Cannot get category IDs for node with ID -> %s", str(node_id))
12706
+ return None
12707
+ category_id = category_ids[0]
12708
+
12709
+ self.logger.debug(
12710
+ "Get category %s form for node ID -> %s and category ID -> %s",
12711
+ operation,
12712
+ str(node_id),
12713
+ str(category_id),
12714
+ )
12715
+
12716
+ request_url = self.config()["nodesFormUrl"] + "/categories/{}?id={}&category_id={}".format(
12717
+ operation, node_id, category_id
12718
+ )
12719
+
12720
+ response = self.do_request(
12721
+ url=request_url,
12722
+ method="GET",
12723
+ headers=request_header,
12724
+ timeout=None,
12725
+ failure_message="Cannot get category {} form for node ID -> {} and category ID -> {}".format(
12726
+ operation,
12727
+ node_id,
12728
+ category_id,
12729
+ ),
12730
+ )
12731
+
12732
+ return response
12733
+
12734
+ # end method definition
12735
+
12068
12736
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="set_system_attributes")
12069
12737
  def set_system_attributes(
12070
12738
  self,
@@ -12465,7 +13133,7 @@ class OTCS:
12465
13133
  request_header = self.request_form_header()
12466
13134
 
12467
13135
  self.logger.debug(
12468
- "Running Web Report with nickname -> '%s'; calling -> %s",
13136
+ "Running web report with nickname -> '%s'; calling -> %s",
12469
13137
  nickname,
12470
13138
  request_url,
12471
13139
  )
@@ -12503,7 +13171,7 @@ class OTCS:
12503
13171
  request_header = self.request_form_header()
12504
13172
 
12505
13173
  self.logger.debug(
12506
- "Install CS Application -> '%s'; calling -> %s",
13174
+ "Install OTCS application -> '%s'; calling -> %s",
12507
13175
  application_name,
12508
13176
  request_url,
12509
13177
  )
@@ -12514,7 +13182,7 @@ class OTCS:
12514
13182
  headers=request_header,
12515
13183
  data=install_cs_application_post_data,
12516
13184
  timeout=None,
12517
- failure_message="Failed to install CS Application -> '{}'".format(
13185
+ failure_message="Failed to install OTCS application -> '{}'".format(
12518
13186
  application_name,
12519
13187
  ),
12520
13188
  )
@@ -12783,6 +13451,64 @@ class OTCS:
12783
13451
 
12784
13452
  # end method definition
12785
13453
 
13454
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="check_user_node_permissions")
13455
+ def check_user_node_permissions(self, node_ids: list[int]) -> dict | None:
13456
+ """Check if the current user has permissions to access a given list of Content Server nodes.
13457
+
13458
+ This is using the AI endpoint has this method is typically used in Aviator use cases.
13459
+
13460
+ Args:
13461
+ node_ids (list[int]):
13462
+ List of node IDs to check.
13463
+
13464
+ Returns:
13465
+ dict | None:
13466
+ REST API response or None in case of an error.
13467
+
13468
+ Example:
13469
+ {
13470
+ 'links': {
13471
+ 'data': {
13472
+ self': {
13473
+ 'body': '',
13474
+ 'content_type': '',
13475
+ 'href': '/api/v2/ai/nodes/permissions/check',
13476
+ 'method': 'POST',
13477
+ 'name': ''
13478
+ }
13479
+ }
13480
+ },
13481
+ 'results': {
13482
+ 'ids': [...]
13483
+ }
13484
+ }
13485
+
13486
+ """
13487
+
13488
+ request_url = self.config()["aiUrl"] + "/permissions/check"
13489
+ request_header = self.request_form_header()
13490
+
13491
+ permission_post_data = {"ids": node_ids}
13492
+
13493
+ if float(self.get_server_version()) < 25.4:
13494
+ permission_post_data["user_hash"] = self.otcs_ticket_hashed()
13495
+
13496
+ self.logger.debug(
13497
+ "Check if current user has permissions to access nodes -> %s; calling -> %s",
13498
+ node_ids,
13499
+ request_url,
13500
+ )
13501
+
13502
+ return self.do_request(
13503
+ url=request_url,
13504
+ method="POST",
13505
+ headers=request_header,
13506
+ data=permission_post_data,
13507
+ failure_message="Failed to check if current user has permissions to access nodes -> {}".format(node_ids),
13508
+ )
13509
+
13510
+ # end method definition
13511
+
12786
13512
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_node_categories")
12787
13513
  def get_node_categories(self, node_id: int, metadata: bool = True) -> dict | None:
12788
13514
  """Get categories assigned to a node.
@@ -12946,7 +13672,7 @@ class OTCS:
12946
13672
 
12947
13673
  Returns:
12948
13674
  dict | None:
12949
- REST esponse with category data or None if the call to the REST API fails.
13675
+ REST response with category data or None if the call to the REST API fails.
12950
13676
 
12951
13677
  """
12952
13678
 
@@ -13209,7 +13935,127 @@ class OTCS:
13209
13935
 
13210
13936
  return cat_id, attribute_definitions
13211
13937
 
13212
- return -1, {}
13938
+ return -1, {}
13939
+
13940
+ # end method definition
13941
+
13942
+ def get_category_id_by_name(self, node_id: int, category_name: str) -> int | None:
13943
+ """Get the category ID by its name.
13944
+
13945
+ Args:
13946
+ node_id (int):
13947
+ The ID of the node to get the categories for.
13948
+ category_name (str):
13949
+ The name of the category to get the ID for.
13950
+
13951
+ Returns:
13952
+ int | None:
13953
+ The category ID or None if the category is not found.
13954
+
13955
+ """
13956
+
13957
+ response = self.get_node_categories(node_id=node_id)
13958
+ results = response["results"]
13959
+ for result in results:
13960
+ categories = result["metadata"]["categories"]
13961
+ first_key = next(iter(categories))
13962
+ if categories[first_key]["name"] == category_name:
13963
+ return first_key
13964
+ return None
13965
+
13966
+ # end method definition
13967
+
13968
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_category_as_dictionary")
13969
+ def get_node_category_as_dictionary(
13970
+ self, node_id: int, category_id: int | None = None, category_name: str | None = None
13971
+ ) -> dict | None:
13972
+ """Get a specific category assigned to a node in a streamlined Python dictionary form.
13973
+
13974
+ * The whole category data of a node is embedded into a python dict.
13975
+ * Single-value / scalar attributes are key / value pairs in that dict.
13976
+ * Multi-value attributes become key / value pairs with value being a list of strings or integers.
13977
+ * Single-line sets become key /value pairs with value being a sub-dict.
13978
+ * Attribute in single-line sets become key / value pairs in the sub-dict.
13979
+ * Multi-line sets become key / value pairs with value being a list of dicts.
13980
+ * Single-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list.
13981
+ * Multi-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list
13982
+ with value being a list of strings or integers.
13983
+
13984
+ See also extract_category_data() for an alternative implementation.
13985
+
13986
+ Args:
13987
+ node_id (int):
13988
+ The ID of the node to get the categories for.
13989
+ category_id (int | None, optional):
13990
+ The node ID of the category definition (in category volume). If not provided,
13991
+ the category ID is determined by its name.
13992
+ category_name (str | None, optional):
13993
+ The name of the category to get the ID for.
13994
+ If category_id is not provided, the category ID is determined by its name.
13995
+
13996
+ Returns:
13997
+ dict | None:
13998
+ REST response with category data or None if the call to the REST API fails.
13999
+
14000
+ """
14001
+
14002
+ if not category_id and not category_name:
14003
+ self.logger.error("Either category ID or category name must be provided!")
14004
+ return None
14005
+
14006
+ if not category_id:
14007
+ category_id = self.get_category_id_by_name(node_id=node_id, category_name=category_name)
14008
+
14009
+ response = self.get_node_category(node_id=node_id, category_id=category_id)
14010
+
14011
+ data = response["results"]["data"]["categories"]
14012
+ metadata = response["results"]["metadata"]["categories"]
14013
+ category_key = next(iter(metadata))
14014
+ _ = metadata.pop(category_key)
14015
+
14016
+ # Initialize the result dict:
14017
+ result = {}
14018
+
14019
+ for key, attribute in metadata.items():
14020
+ is_set = attribute["persona"] == "set"
14021
+ is_multi_value = attribute["multi_value"]
14022
+ attr_name = attribute["name"]
14023
+ attr_key = attribute["key"]
14024
+
14025
+ if is_set:
14026
+ set_name = attr_name
14027
+ set_multi_value = is_multi_value
14028
+
14029
+ if not is_set and "x" not in attr_key:
14030
+ result[attr_name] = data[key]
14031
+ set_name = None
14032
+ elif is_set:
14033
+ # The current attribute is the set itself:
14034
+ if not is_multi_value:
14035
+ result[attr_name] = {}
14036
+ else:
14037
+ result[attr_name] = []
14038
+ set_name = attr_name
14039
+ elif not is_set and "x" in attr_key:
14040
+ # We re inside a set and process the set attributes:
14041
+ if not set_multi_value:
14042
+ # A single row set:
14043
+ attr_key = attr_key.replace("_x_", "_1_")
14044
+ result[set_name][attr_name] = data[attr_key]
14045
+ else:
14046
+ # Collect all the row data:
14047
+ for index in range(1, 50):
14048
+ attr_key_index = attr_key.replace("_x_", "_" + str(index) + "_")
14049
+ # Do we have data for this row?
14050
+ if attr_key_index in data:
14051
+ if index > len(result[set_name]):
14052
+ result[set_name].append({attr_name: data[attr_key_index]})
14053
+ else:
14054
+ result[set_name][index - 1][attr_name] = data[attr_key_index]
14055
+ else:
14056
+ # No more rows
14057
+ break
14058
+ return result
13213
14059
 
13214
14060
  # end method definition
13215
14061
 
@@ -13223,6 +14069,7 @@ class OTCS:
13223
14069
  apply_action: str = "add_upgrade",
13224
14070
  add_version: bool = False,
13225
14071
  clear_existing_categories: bool = False,
14072
+ attribute_values: dict | None = None,
13226
14073
  ) -> bool:
13227
14074
  """Assign a category to a Content Server node.
13228
14075
 
@@ -13230,6 +14077,7 @@ class OTCS:
13230
14077
  (if node_id is a container / folder / workspace).
13231
14078
  If the category is already assigned to the node this method will
13232
14079
  throw an error.
14080
+ Optionally set category attributes values.
13233
14081
 
13234
14082
  Args:
13235
14083
  node_id (int):
@@ -13250,6 +14098,9 @@ class OTCS:
13250
14098
  True, if a document version should be added for the category change (default = False).
13251
14099
  clear_existing_categories (bool, optional):
13252
14100
  Defines, whether or not existing (other) categories should be removed (default = False).
14101
+ attribute_values (dict, optional):
14102
+ Dictionary containing "attribute_id":"value" pairs, to be populated during the category assignment.
14103
+ (In case of the category attributes being set as "Required" in xECM, providing corresponding values for those attributes will resolve inability to assign the category).
13253
14104
 
13254
14105
  Returns:
13255
14106
  bool:
@@ -13275,6 +14126,9 @@ class OTCS:
13275
14126
  "category_id": category_id,
13276
14127
  }
13277
14128
 
14129
+ if attribute_values is not None:
14130
+ category_post_data.update(attribute_values)
14131
+
13278
14132
  self.logger.debug(
13279
14133
  "Assign category with ID -> %d to item with ID -> %d; calling -> %s",
13280
14134
  category_id,
@@ -13735,6 +14589,8 @@ class OTCS:
13735
14589
  * Multi-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list
13736
14590
  with value being a list of strings or integers.
13737
14591
 
14592
+ See also get_node_category_as_dictionary() for an alternative implementation.
14593
+
13738
14594
  Args:
13739
14595
  node (dict):
13740
14596
  The typical node response of a node get REST API call that include the "categories" fields.
@@ -13806,14 +14662,37 @@ class OTCS:
13806
14662
 
13807
14663
  # Start of main method body:
13808
14664
 
13809
- if not node or "results" not in node:
14665
+ if not node:
14666
+ self.logger.error("Cannot extract category data. No node data provided!")
13810
14667
  return None
13811
14668
 
14669
+ if "results" not in node:
14670
+ # Support also iterators that have resolved the "results" already.
14671
+ # In this case we wrap it in a "rsults" dict to make it look like
14672
+ # a full response:
14673
+ if "data" in node:
14674
+ node = {"results": node}
14675
+ else:
14676
+ return None
14677
+
14678
+ # Some OTCS REST APIs may return a list of nodes in "results".
14679
+ # We only support processing a single node here:
14680
+ if isinstance(node["results"], list):
14681
+ if len(node["results"]) > 1:
14682
+ self.logger.warning("Response includes a node list. Extracting category data for the first node!")
14683
+ node["results"] = node["results"][0]
14684
+
13812
14685
  if "metadata" not in node["results"]:
13813
- self.logger.error("Cannot exteact metadata. Node method was called without the '&metadata' parameter!")
14686
+ self.logger.error("Cannot extract category data. Method was called without the '&metadata' parameter!")
13814
14687
  return None
13815
14688
 
13816
- category_schemas = node["results"]["metadata"]["categories"]
14689
+ metadata = node["results"]["metadata"]
14690
+ if "categories" not in metadata:
14691
+ self.logger.error(
14692
+ "Cannot extract category data. No category data found in node response! Use 'categories' value for 'fields' parameter in the node call!"
14693
+ )
14694
+ return None
14695
+ category_schemas = metadata["categories"]
13817
14696
 
13818
14697
  result_dict = {}
13819
14698
  current_dict = result_dict
@@ -13821,6 +14700,15 @@ class OTCS:
13821
14700
  category_lookup = {}
13822
14701
  attribute_lookup = {}
13823
14702
 
14703
+ # Some REST API return categories in different format. We adjust
14704
+ # it on the fly here:
14705
+ if isinstance(category_schemas, list):
14706
+ new_schema = {}
14707
+ for category_schema in category_schemas:
14708
+ first_key = next(iter(category_schema))
14709
+ new_schema[first_key] = category_schema
14710
+ category_schemas = new_schema
14711
+
13824
14712
  try:
13825
14713
  for category_key, category_schema in category_schemas.items():
13826
14714
  for attribute_key, attribute_schema in category_schema.items():
@@ -13888,9 +14776,17 @@ class OTCS:
13888
14776
  self.logger.error("Type -> '%s' not handled yet!", attribute_type)
13889
14777
  except Exception as e:
13890
14778
  self.logger.error("Something went wrong with getting the data schema! Error -> %s", str(e))
14779
+ return None
13891
14780
 
13892
14781
  category_datas = node["results"]["data"]["categories"]
13893
14782
 
14783
+ if isinstance(category_datas, list):
14784
+ new_data = {}
14785
+ for category_data in category_datas:
14786
+ first_key = next(iter(category_data))
14787
+ new_data[first_key] = category_data
14788
+ category_datas = new_data
14789
+
13894
14790
  try:
13895
14791
  for category_data in category_datas.values():
13896
14792
  for attribute_key, value in category_data.items():
@@ -13911,7 +14807,7 @@ class OTCS:
13911
14807
  current_dict = set_data
13912
14808
  elif isinstance(set_data, list):
13913
14809
  row_number = get_row_number(attribute_key=attribute_key)
13914
- self.logger.debug("Taget dict is row %d of multi-line set attribute.", row_number)
14810
+ self.logger.debug("Target dict is row %d of multi-line set attribute.", row_number)
13915
14811
  if row_number > len(set_data):
13916
14812
  self.logger.debug("Add rows up to %d of multi-line set attribute...", row_number)
13917
14813
  for _ in range(row_number - len(set_data)):
@@ -13927,6 +14823,7 @@ class OTCS:
13927
14823
  current_dict[attribute_name] = value
13928
14824
  except Exception as e:
13929
14825
  self.logger.error("Something went wrong while filling the data! Error -> %s", str(e))
14826
+ return None
13930
14827
 
13931
14828
  return result_dict
13932
14829
 
@@ -14311,7 +15208,7 @@ class OTCS:
14311
15208
 
14312
15209
  Args:
14313
15210
  node_id (int):
14314
- The node ID of the Extended ECM workspace template.
15211
+ The node ID of the Business Workspace template.
14315
15212
 
14316
15213
  Returns:
14317
15214
  dict | None:
@@ -16468,6 +17365,7 @@ class OTCS:
16468
17365
 
16469
17366
  """
16470
17367
 
17368
+ # If no sub-process ID is given, use the process ID:
16471
17369
  if subprocess_id is None:
16472
17370
  subprocess_id = process_id
16473
17371
 
@@ -16679,7 +17577,7 @@ class OTCS:
16679
17577
  "enabled": status,
16680
17578
  }
16681
17579
 
16682
- request_url = self.config()["aiUrl"] + "/{}".format(workspace_id)
17580
+ request_url = self.config()["aiNodesUrl"] + "/{}".format(workspace_id)
16683
17581
  request_header = self.request_form_header()
16684
17582
 
16685
17583
  if status is True:
@@ -16708,6 +17606,181 @@ class OTCS:
16708
17606
 
16709
17607
  # end method definition
16710
17608
 
17609
+ def aviator_chat(
17610
+ self, context: str | None, messages: list[dict], where: list[dict] | None = None, inline_citation: bool = True
17611
+ ) -> dict | None:
17612
+ """Process a chat interaction with Content Aviator.
17613
+
17614
+ Args:
17615
+ context (str | None):
17616
+ Context for the current conversation. This includes the text chunks
17617
+ provided by the RAG pipeline.
17618
+ messages (list[dict]):
17619
+ List of messages from conversation history.
17620
+ Format example:
17621
+ [
17622
+ {
17623
+ "author": "user", "content": "Summarize this workspace, please."
17624
+ },
17625
+ {
17626
+ "author": "ai", "content": "..."
17627
+ }
17628
+ ]
17629
+ where (list):
17630
+ Metadata name/value pairs for the query.
17631
+ Could be used to specify workspaces, documents, or other criteria in the future.
17632
+ Values need to match those passed as metadata to the embeddings API.
17633
+ Format example:
17634
+ [
17635
+ {"workspaceID":"38673"},
17636
+ {"documentID":"38458"},
17637
+ ]
17638
+ inline_citation (bool, optional):
17639
+ Whether or not inline citations should be used in the response. Default is True.
17640
+
17641
+ Returns:
17642
+ dict:
17643
+ Conversation status
17644
+
17645
+ Example:
17646
+ {
17647
+ 'result': 'I am unable to provide the three main regulations for fuel, as the documents contain various articles and specifications related to fuel, but do not explicitly identify which three are the "main" ones.',
17648
+ 'references': [
17649
+ {
17650
+ 'chunks': [
17651
+ {
17652
+ 'citation': None,
17653
+ 'content': ['16. 1 Basic principles 16.'],
17654
+ 'distance': 0.262610273676197,
17655
+ 'source': 'Similarity'
17656
+ }
17657
+ ],
17658
+ 'distance': 0.262610273676197,
17659
+ 'metadata': {
17660
+ 'content': {
17661
+ 'chunks': ['16. 1 Basic principles 16.'],
17662
+ 'source': 'Similarity'
17663
+ },
17664
+ 'documentID': '39004',
17665
+ 'workspaceID': '38673'
17666
+ }
17667
+ },
17668
+ {
17669
+ 'chunks': [
17670
+ {
17671
+ 'citation': None,
17672
+ 'content': ['16. 1.'],
17673
+ 'distance': 0.284182507756566,
17674
+ 'source': 'Similarity'
17675
+ }
17676
+ ],
17677
+ 'distance': 0.284182507756566,
17678
+ 'metadata': {
17679
+ 'content': {
17680
+ 'chunks': ['16. 1.'],
17681
+ 'source': 'Similarity'
17682
+ },
17683
+ 'documentID': '38123',
17684
+ 'workspaceID': '38673'
17685
+ }
17686
+ }
17687
+ ],
17688
+ 'context': 'Tool "get_context" called with arguments {"query":"Tell me about the calibration equipment"} and returned:',
17689
+ 'queryMetadata': {
17690
+ 'originalQuery': 'Tell me about the calibration equipment',
17691
+ 'usedQuery': 'Tell me about the calibration equipment'
17692
+ }
17693
+ }
17694
+
17695
+
17696
+
17697
+ """
17698
+
17699
+ request_url = self.config()["aiChatUrl"]
17700
+ request_header = self.request_form_header()
17701
+
17702
+ chat_data = {}
17703
+ if where:
17704
+ chat_data["where"] = where
17705
+
17706
+ chat_data["context"] = context
17707
+ chat_data["messages"] = messages
17708
+ # "synonyms": self.config()["synonyms"],
17709
+ chat_data["inlineCitation"] = inline_citation
17710
+
17711
+ return self.do_request(
17712
+ url=request_url,
17713
+ method="POST",
17714
+ headers=request_header,
17715
+ # data=chat_data,
17716
+ data={"body": json.dumps(chat_data)},
17717
+ timeout=None,
17718
+ failure_message="Failed to chat with Content Aviator",
17719
+ )
17720
+
17721
+ # end method definition
17722
+
17723
+ def aviator_context(
17724
+ self, query: str, threshold: float = 0.5, limit: int = 10, data: list | None = None
17725
+ ) -> dict | None:
17726
+ """Get context based on the query text from Aviator's vector database.
17727
+
17728
+ Results are text-chunks and they will be permission-checked for the authenticated user.
17729
+
17730
+ Args:
17731
+ query (str):
17732
+ The query text to search for similar text chunks.
17733
+ threshold (float, optional):
17734
+ Similarity threshold between 0 and 1. Default is 0.5.
17735
+ limit (int, optional):
17736
+ Maximum number of results to return. Default is 10.
17737
+ data (list | None, optional):
17738
+ Additional data to pass to the embeddings API. Defaults to None.
17739
+ This can include metadata for filtering the results.
17740
+
17741
+ Returns:
17742
+ dict | None:
17743
+ The response from the embeddings API or None if the request fails.
17744
+
17745
+ """
17746
+
17747
+ request_url = self.config()["aiContextUrl"]
17748
+ request_header = self.request_form_header()
17749
+
17750
+ if not query:
17751
+ self.logger.error("Query text is required for getting context from Content Aviator!")
17752
+ return None
17753
+
17754
+ context_post_body = {
17755
+ "query": query,
17756
+ "threshold": threshold,
17757
+ "limit": limit,
17758
+ }
17759
+ if data:
17760
+ context_post_body["data"] = data
17761
+ else:
17762
+ context_post_body["data"] = []
17763
+
17764
+ self.logger.debug(
17765
+ "Get context from Content Aviator for query -> '%s' (threshold: %f, limit: %d); calling -> %s",
17766
+ query,
17767
+ threshold,
17768
+ limit,
17769
+ request_url,
17770
+ )
17771
+
17772
+ return self.do_request(
17773
+ url=request_url,
17774
+ method="POST",
17775
+ headers=request_header,
17776
+ # data={"body": json.dumps(context_post_body)},
17777
+ data=context_post_body,
17778
+ timeout=None,
17779
+ failure_message="Failed to retrieve context from Content Aviator",
17780
+ )
17781
+
17782
+ # end method definition
17783
+
16711
17784
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="traverse_node")
16712
17785
  def traverse_node(
16713
17786
  self,
@@ -16788,7 +17861,8 @@ class OTCS:
16788
17861
  for subnode in subnodes:
16789
17862
  subnode_id = self.get_result_value(response=subnode, key="id")
16790
17863
  subnode_name = self.get_result_value(response=subnode, key="name")
16791
- self.logger.info("Traversing node -> '%s' (%s)", subnode_name, str(subnode_id))
17864
+ subnode_type_name = self.get_result_value(response=subnode, key="type_name")
17865
+ self.logger.info("Traversing %s node -> '%s' (%s)", subnode_type_name, subnode_name, subnode_id)
16792
17866
  # Recursive call for current subnode:
16793
17867
  result = self.traverse_node(
16794
17868
  node=subnode,
@@ -16866,7 +17940,13 @@ class OTCS:
16866
17940
  task_queue.put((subnode, 0, traversal_data))
16867
17941
 
16868
17942
  def traverse_node_worker() -> None:
16869
- """Work on queue.
17943
+ """Work on a shared queue.
17944
+
17945
+ Loops over these steps:
17946
+ 1. Get node from queue
17947
+ 2. Execute all executables for that node
17948
+ 3. If node is a container and executables indicate to traverse,
17949
+ then enqueue all subnodes
16870
17950
 
16871
17951
  Returns:
16872
17952
  None
@@ -16903,6 +17983,17 @@ class OTCS:
16903
17983
 
16904
17984
  # Run all executables
16905
17985
  for executable in executables or []:
17986
+ # The executables are functions or method from outside this class.
17987
+ # They need to return a tuple of two boolean values:
17988
+ # (result_success, result_traverse)
17989
+ # result_success indicates if the executable was successful (True)
17990
+ # or not (False). If False, the execution of the executables list
17991
+ # is stopped.
17992
+ # result_traverse indicates if the traversal should continue
17993
+ # into subnodes (True) or not (False).
17994
+ # If at least one executable returns result_traverse = True,
17995
+ # then the traversal into subnodes will be done (if the node is a container).
17996
+ # As this code is from outside this class, we better catch exceptions:
16906
17997
  try:
16907
17998
  result_success, result_traverse = executable(
16908
17999
  node=node,
@@ -17877,7 +18968,8 @@ class OTCS:
17877
18968
  Defaults to None = filter not active.
17878
18969
  filter_subtypes (list | None, optional):
17879
18970
  Additive filter criterium for item type.
17880
- Defaults to None = filter not active.
18971
+ Defaults to None = filter not active. filter_subtypes = [] is different from None!
18972
+ If an empty list is provided, the filter is effectively always True.
17881
18973
  filter_category (str | None, optional):
17882
18974
  Additive filter criterium for existence of a category on the node.
17883
18975
  The value of filter_category is the name of the category
@@ -17910,7 +19002,7 @@ class OTCS:
17910
19002
  self.logger.error("Illegal node - cannot apply filter!")
17911
19003
  return False
17912
19004
 
17913
- if filter_subtypes and node["type"] not in filter_subtypes:
19005
+ if filter_subtypes is not None and node["type"] not in filter_subtypes:
17914
19006
  self.logger.debug(
17915
19007
  "Node type -> '%s' is not in filter node types -> %s. Node -> '%s' failed filter test.",
17916
19008
  node["type"],
@@ -18623,6 +19715,8 @@ class OTCS:
18623
19715
  def load_items(
18624
19716
  self,
18625
19717
  node_id: int,
19718
+ workspaces: bool = True,
19719
+ items: bool = True,
18626
19720
  filter_workspace_depth: int | None = None,
18627
19721
  filter_workspace_subtypes: list | None = None,
18628
19722
  filter_workspace_category: str | None = None,
@@ -18647,6 +19741,12 @@ class OTCS:
18647
19741
  Args:
18648
19742
  node_id (int):
18649
19743
  The root Node ID the traversal should start at.
19744
+ workspaces (bool, optional):
19745
+ If True, workspaces are included in the data frame.
19746
+ Defaults to True.
19747
+ items (bool, optional):
19748
+ If True, document items are included in the data frame.
19749
+ Defaults to True.
18650
19750
  filter_workspace_depth (int | None, optional):
18651
19751
  Additive filter criterium for workspace path depth.
18652
19752
  Defaults to None = filter not active.
@@ -18695,9 +19795,30 @@ class OTCS:
18695
19795
  dict:
18696
19796
  Stats with processed and traversed counters.
18697
19797
 
19798
+ Side Effects:
19799
+ The resulting data frame is stored in self._data. It will have the following columns:
19800
+ - type which is either "item" or "workspace"
19801
+ - workspace_type
19802
+ - workspace_id
19803
+ - workspace_name
19804
+ - workspace_description
19805
+ - workspace_outer_path
19806
+ - workspace_<cat_id>_<attr_id> for each workspace attribute if workspace_metadata is True
19807
+ - item_id
19808
+ - item_type
19809
+ - item_name
19810
+ - item_description
19811
+ - item_path
19812
+ - item_download_name
19813
+ - item_mime_type
19814
+ - item_url
19815
+ - item_<cat_id>_<attr_id> for each item attribute if item_metadata is True
19816
+ - item_cat_<cat_id>_<attr_id> for each item attribute if item_metadata is True and self._use_numeric_category_identifier is True
19817
+ - item_cat_<cat_name>_<attr_name> for each item attribute if item_metadata is True and self._use_numeric_category_identifier is False
19818
+
18698
19819
  """
18699
19820
 
18700
- # Initiaze download threads for this subnode:
19821
+ # Initiaze download threads for document items:
18701
19822
  download_threads = []
18702
19823
 
18703
19824
  def check_node_exclusions(node: dict, **kwargs: dict) -> tuple[bool, bool]:
@@ -18718,15 +19839,14 @@ class OTCS:
18718
19839
 
18719
19840
  """
18720
19841
 
18721
- exclude_node_ids = kwargs.get("exclude_node_ids")
18722
- if exclude_node_ids is None:
18723
- self.logger.error("Missing keyword arguments for executable in node traversal!")
18724
- return (False, False)
19842
+ # Get the list of node IDs to exclude from the keyword arguments.
19843
+ # If not provided, use an empty list as default which means no exclusions.
19844
+ exclude_node_ids = kwargs.get("exclude_node_ids") or []
18725
19845
 
18726
19846
  node_id = self.get_result_value(response=node, key="id")
18727
19847
  node_name = self.get_result_value(response=node, key="name")
18728
19848
 
18729
- if node_id and exclude_node_ids is not None and (node_id in exclude_node_ids):
19849
+ if node_id and (node_id in exclude_node_ids):
18730
19850
  self.logger.info(
18731
19851
  "Node -> '%s' (%s) is in exclusion list. Skip traversal of this node.",
18732
19852
  node_name,
@@ -18753,13 +19873,36 @@ class OTCS:
18753
19873
 
18754
19874
  """
18755
19875
 
19876
+ # This should actually not happen as the caller should
19877
+ # check if workspaces are requested before calling this function.
19878
+ if not workspaces:
19879
+ # Success = False, Traverse = True
19880
+ return (False, True)
19881
+
18756
19882
  traversal_data = kwargs.get("traversal_data")
18757
19883
  filter_workspace_data = kwargs.get("filter_workspace_data")
18758
19884
  control_flags = kwargs.get("control_flags")
18759
19885
 
18760
- if not traversal_data or not filter_workspace_data or not control_flags:
18761
- self.logger.error("Missing keyword arguments for executable in node traversal!")
18762
- return False
19886
+ if not traversal_data:
19887
+ self.logger.error(
19888
+ "Missing keyword argument 'traversal_data' for executable 'check_node_workspace' in node traversal!"
19889
+ )
19890
+ # Success = False, Traverse = False
19891
+ return (False, False)
19892
+
19893
+ if not filter_workspace_data:
19894
+ self.logger.error(
19895
+ "Missing keyword argument 'filter_workspace_data' for executable 'check_node_workspace' in node traversal!"
19896
+ )
19897
+ # Success = False, Traverse = False
19898
+ return (False, False)
19899
+
19900
+ if not control_flags:
19901
+ self.logger.error(
19902
+ "Missing keyword argument 'control_flags' for executable 'check_node_workspace' in node traversal!"
19903
+ )
19904
+ # Success = False, Traverse = False
19905
+ return (False, False)
18763
19906
 
18764
19907
  node_id = self.get_result_value(response=node, key="id")
18765
19908
  node_name = self.get_result_value(response=node, key="name")
@@ -18767,10 +19910,10 @@ class OTCS:
18767
19910
  node_type = self.get_result_value(response=node, key="type")
18768
19911
 
18769
19912
  #
18770
- # 1. Check if the traversal is already inside a workflow. Then we can skip
18771
- # the workspace processing. We currently don't support sub-workspaces.
19913
+ # 1. Check if the traversal is already inside a workspace. Then we can skip
19914
+ # the workspace processing as we currently don't support sub-workspaces.
18772
19915
  #
18773
- workspace_id = traversal_data["workspace_id"]
19916
+ workspace_id = traversal_data.get("workspace_id")
18774
19917
  if workspace_id:
18775
19918
  self.logger.debug(
18776
19919
  "Found folder or workspace -> '%s' (%s) inside workspace with ID -> %d. So this container cannot be a workspace.",
@@ -18801,7 +19944,7 @@ class OTCS:
18801
19944
  categories = None
18802
19945
 
18803
19946
  #
18804
- # 3. Apply the defined filters to the current node to see
19947
+ # 3. Apply the defined workspace filters to the current node to see
18805
19948
  # if we want to 'interpret' it as a workspace
18806
19949
  #
18807
19950
  # See if it is a node that we want to interpret as a workspace.
@@ -18818,6 +19961,13 @@ class OTCS:
18818
19961
  filter_category=filter_workspace_data["filter_workspace_category"],
18819
19962
  filter_attributes=filter_workspace_data["filter_workspace_attributes"],
18820
19963
  ):
19964
+ self.logger.debug(
19965
+ "Node -> '%s' (%s) did not match workspace filter -> %s",
19966
+ node_name,
19967
+ node_id,
19968
+ str(filter_workspace_data),
19969
+ )
19970
+
18821
19971
  # Success = False, Traverse = True
18822
19972
  return (False, True)
18823
19973
 
@@ -18831,7 +19981,7 @@ class OTCS:
18831
19981
  #
18832
19982
  # 4. Create the data frame row from the node / traversal data:
18833
19983
  #
18834
- row = {}
19984
+ row = {"type": "workspace"}
18835
19985
  row["workspace_type"] = node_type
18836
19986
  row["workspace_id"] = node_id
18837
19987
  row["workspace_name"] = node_name
@@ -18855,7 +20005,7 @@ class OTCS:
18855
20005
  traversal_data["workspace_description"] = node_description
18856
20006
  self.logger.debug("Updated traversal data -> %s", str(traversal_data))
18857
20007
 
18858
- # Success = True, Traverse = False
20008
+ # Success = True, Traverse = True
18859
20009
  # We have traverse = True because we need to
18860
20010
  # keep traversing into the workspace folders.
18861
20011
  return (True, True)
@@ -18882,8 +20032,16 @@ class OTCS:
18882
20032
  filter_item_data = kwargs.get("filter_item_data")
18883
20033
  control_flags = kwargs.get("control_flags")
18884
20034
 
18885
- if not traversal_data or not filter_item_data or not control_flags:
18886
- self.logger.error("Missing keyword arguments for executable in node item traversal!")
20035
+ if not traversal_data:
20036
+ self.logger.error("Missing keyword argument 'traversal_data' for executable in node item traversal!")
20037
+ return (False, False)
20038
+
20039
+ if not filter_item_data:
20040
+ self.logger.error("Missing keyword argument 'filter_item_data' for executable in node item traversal!")
20041
+ return (False, False)
20042
+
20043
+ if not control_flags:
20044
+ self.logger.error("Missing keyword argument 'control_flags' for executable in node item traversal!")
18887
20045
  return (False, False)
18888
20046
 
18889
20047
  node_id = self.get_result_value(response=node, key="id")
@@ -18918,7 +20076,7 @@ class OTCS:
18918
20076
  categories = None
18919
20077
 
18920
20078
  #
18921
- # 2. Apply the defined filters to the current node to see
20079
+ # 2. Apply the defined item filters to the current node to see
18922
20080
  # if we want to add it to the data frame as an item.
18923
20081
  #
18924
20082
  # If filter_item_in_workspace is false, then documents
@@ -18935,10 +20093,17 @@ class OTCS:
18935
20093
  filter_category=filter_item_data["filter_item_category"],
18936
20094
  filter_attributes=filter_item_data["filter_item_attributes"],
18937
20095
  ):
20096
+ self.logger.debug(
20097
+ "Node -> '%s' (%s) did not match item filter -> %s",
20098
+ node_name,
20099
+ node_id,
20100
+ str(filter_item_data),
20101
+ )
20102
+
18938
20103
  # Success = False, Traverse = True
18939
20104
  return (False, True)
18940
20105
 
18941
- # We only consider documents that are inside the defined "workspaces":
20106
+ # Debug output where we found the item (inside or outside of workspace):
18942
20107
  if workspace_id:
18943
20108
  self.logger.debug(
18944
20109
  "Found %s item -> '%s' (%s) in depth -> %s inside workspace -> '%s' (%s).",
@@ -18971,15 +20136,19 @@ class OTCS:
18971
20136
  if control_flags["download_documents"] and (
18972
20137
  not os.path.exists(file_path) or not control_flags["skip_existing_downloads"]
18973
20138
  ):
18974
- #
18975
- # Start anasynchronous Download Thread:
18976
- #
20139
+ mime_type = self.get_result_value(response=node, key="mime_type")
20140
+ extract_after_download = mime_type == "application/x-zip-compressed" and extract_zip
18977
20141
  self.logger.debug(
18978
- "Downloading file -> '%s'...",
20142
+ "Downloading document -> '%s' (%s) to temp file -> '%s'%s...",
20143
+ node_name,
20144
+ mime_type,
18979
20145
  file_path,
20146
+ " and extracting it after download" if extract_after_download else "",
18980
20147
  )
18981
20148
 
18982
- extract_after_download = node["mime_type"] == "application/x-zip-compressed" and extract_zip
20149
+ #
20150
+ # Start asynchronous Download Thread:
20151
+ #
18983
20152
  thread = threading.Thread(
18984
20153
  target=self.download_document_multi_threading,
18985
20154
  args=(node_id, file_path, extract_after_download),
@@ -18989,7 +20158,8 @@ class OTCS:
18989
20158
  download_threads.append(thread)
18990
20159
  else:
18991
20160
  self.logger.debug(
18992
- "File -> %s has been downloaded before or download is not requested. Skipping download...",
20161
+ "Document -> '%s' has been downloaded to file -> %s before or download is not requested. Skipping download...",
20162
+ node_name,
18993
20163
  file_path,
18994
20164
  )
18995
20165
  # end if document
@@ -18998,26 +20168,36 @@ class OTCS:
18998
20168
  # Construct a dictionary 'row' that we will add
18999
20169
  # to the resulting data frame:
19000
20170
  #
19001
- row = {}
19002
- # First we include some key workspace data to associate
19003
- # the item with the workspace:
19004
- row["workspace_type"] = workspace_type
19005
- row["workspace_id"] = workspace_id
19006
- row["workspace_name"] = workspace_name
19007
- row["workspace_description"] = workspace_description
20171
+ row = {"type": "item"}
20172
+ if workspaces:
20173
+ # First we include some key workspace data to associate
20174
+ # the item with the workspace:
20175
+ row["workspace_type"] = workspace_type
20176
+ row["workspace_id"] = workspace_id
20177
+ row["workspace_name"] = workspace_name
20178
+ row["workspace_description"] = workspace_description
19008
20179
  # Then add item specific data:
19009
20180
  row["item_id"] = str(node_id)
19010
20181
  row["item_type"] = node_type
19011
20182
  row["item_name"] = node_name
19012
20183
  row["item_description"] = node_description
19013
- # We take the sub-path of the folder path inside the workspace
20184
+ row["item_path"] = []
20185
+ # We take the part of folder path which is inside the workspace
19014
20186
  # as the item path:
19015
- try:
19016
- # Item path are the list elements after the item that is the workspace name:
19017
- row["item_path"] = folder_path[folder_path.index(workspace_name) + 1 :]
19018
- except ValueError:
19019
- self.logger.warning("Cannot access folder path while processing -> '%s' (%s)!", node_name, node_id)
19020
- row["item_path"] = []
20187
+ if (
20188
+ folder_path and workspace_name and workspace_name in folder_path
20189
+ ): # check if folder_path is not empty, this can happy if document items are the workspace items
20190
+ try:
20191
+ # Item path are the list elements after the item that is the workspace name:
20192
+ row["item_path"] = folder_path[folder_path.index(workspace_name) + 1 :]
20193
+ except ValueError:
20194
+ self.logger.warning(
20195
+ "Cannot find workspace name -> '%s' in folder path -> %s while processing -> '%s' (%s)!",
20196
+ workspace_name,
20197
+ folder_path,
20198
+ node_name,
20199
+ node_id,
20200
+ )
19021
20201
  row["item_download_name"] = str(node_id) if node_type == self.ITEM_TYPE_DOCUMENT else ""
19022
20202
  row["item_mime_type"] = (
19023
20203
  self.get_result_value(response=node, key="mime_type") if node_type == self.ITEM_TYPE_DOCUMENT else ""
@@ -19025,10 +20205,10 @@ class OTCS:
19025
20205
  # URL specific data:
19026
20206
  row["item_url"] = self.get_result_value(response=node, key="url") if node_type == self.ITEM_TYPE_URL else ""
19027
20207
  if item_metadata and categories and categories["results"]:
19028
- # Add columns for workspace node categories have been determined above.
20208
+ # Add columns for item node categories have been determined above.
19029
20209
  self.add_attribute_columns(row=row, categories=categories, prefix="item_cat_")
19030
20210
 
19031
- # Now we add the row to the Pandas Data Frame in the Data class:
20211
+ # Now we add the item row to the Pandas Data Frame in the Data class:
19032
20212
  self.logger.info(
19033
20213
  "Adding %s -> '%s' (%s) to data frame...",
19034
20214
  "document" if node_type == self.ITEM_TYPE_DOCUMENT else "URL",
@@ -19038,7 +20218,9 @@ class OTCS:
19038
20218
  with self._data.lock():
19039
20219
  self._data.append(row)
19040
20220
 
19041
- return True
20221
+ # Success = True, Traverse = False
20222
+ # We have traverse = False because document or URL items have no sub-items.
20223
+ return (True, False)
19042
20224
 
19043
20225
  # end check_node_item()
19044
20226
 
@@ -19076,19 +20258,37 @@ class OTCS:
19076
20258
  "extract_zip": extract_zip,
19077
20259
  }
19078
20260
 
20261
+ #
20262
+ # Define the list of executables to call for each node:
20263
+ #
20264
+ executables = []
20265
+ if workspaces:
20266
+ executables.append(check_node_workspace)
20267
+ if items:
20268
+ executables.append(check_node_item)
20269
+ if not executables:
20270
+ self.logger.error("Neither workspaces nor items are requested to be loaded. Nothing to do!")
20271
+ return None
20272
+
19079
20273
  #
19080
20274
  # Start the traversal of the nodes:
19081
20275
  #
19082
20276
  result = self.traverse_node_parallel(
19083
20277
  node=node_id,
20278
+ # For each node we call these executables in this order to check if
20279
+ # the node should be added to the resulting data frame:
19084
20280
  executables=[check_node_exclusions, check_node_workspace, check_node_item],
20281
+ workers=workers, # number of worker threads
19085
20282
  exclude_node_ids=exclude_node_ids,
19086
20283
  filter_workspace_data=filter_workspace_data,
19087
20284
  filter_item_data=filter_item_data,
19088
20285
  control_flags=control_flags,
19089
- workers=workers, # number of worker threads
19090
20286
  )
19091
20287
 
20288
+ # Wait for all download threads to complete:
20289
+ for thread in download_threads:
20290
+ thread.join()
20291
+
19092
20292
  return result
19093
20293
 
19094
20294
  # end method definition
@@ -19177,7 +20377,7 @@ class OTCS:
19177
20377
  }
19178
20378
  if message_override:
19179
20379
  message.update(message_override)
19180
- self.logger.info(
20380
+ self.logger.debug(
19181
20381
  "Start Content Aviator embedding on -> '%s' (%s), type -> %s, crawl -> %s, wait for completion -> %s, workspaces -> %s, documents -> %s, images -> %s",
19182
20382
  node_properties["name"],
19183
20383
  node_properties["id"],
@@ -19188,7 +20388,7 @@ class OTCS:
19188
20388
  document_metadata,
19189
20389
  images,
19190
20390
  )
19191
- self.logger.debug("Sending WebSocket message -> %s", message)
20391
+ self.logger.debug("Sending WebSocket message -> %s...", message)
19192
20392
  await websocket.send(message=json.dumps(message))
19193
20393
 
19194
20394
  # Continuously listen for messages
@@ -19295,3 +20495,170 @@ class OTCS:
19295
20495
  return success
19296
20496
 
19297
20497
  # end method definition
20498
+
20499
+ def _get_document_template_raw(self, workspace_id: int) -> ET.Element | None:
20500
+ """Get the raw template XML payload from a workspace.
20501
+
20502
+ Args:
20503
+ workspace_id (int):
20504
+ The ID of the workspace to generate the document from.
20505
+
20506
+ Returns:
20507
+ ET.Element | None:
20508
+ The XML Element with the payload to initiate a document generation, or None if an error occurred
20509
+
20510
+ """
20511
+
20512
+ request_url = self.config()["csUrl"]
20513
+
20514
+ request_header = self.request_form_header()
20515
+ request_header["referer"] = "http://localhost"
20516
+
20517
+ data = {
20518
+ "func": "xecmpfdocgen.PowerDocsPayload",
20519
+ "wsId": str(workspace_id),
20520
+ "hideHeader": "true",
20521
+ "source": "CreateDocument",
20522
+ }
20523
+
20524
+ self.logger.debug(
20525
+ "Get document templates for workspace with ID -> %d; calling -> %s",
20526
+ workspace_id,
20527
+ request_url,
20528
+ )
20529
+
20530
+ response = self.do_request(
20531
+ url=request_url,
20532
+ method="POST",
20533
+ headers=request_header,
20534
+ data=data,
20535
+ timeout=None,
20536
+ failure_message="Failed to get document templates for workspace with ID -> {}".format(workspace_id),
20537
+ parse_request_response=False,
20538
+ )
20539
+
20540
+ if response is None:
20541
+ return None
20542
+
20543
+ try:
20544
+ text = response.text
20545
+ match = re.search(r'<textarea[^>]*name=["\']documentgeneration["\'][^>]*>(.*?)</textarea>', text, re.DOTALL)
20546
+ textarea_content = match.group(1).strip() if match else ""
20547
+ textarea_content = html.unescape(textarea_content)
20548
+
20549
+ # Load Payload into XML object
20550
+ # payload is an XML formatted string, load it into an XML object for further processing
20551
+
20552
+ root = ET.Element("documentgeneration", format="pdf")
20553
+ root.append(ET.fromstring(textarea_content))
20554
+ except (ET.ParseError, AttributeError) as exc:
20555
+ self.logger.error(
20556
+ "Cannot parse document template XML payload for workspace with ID -> %d! Error -> %s",
20557
+ workspace_id,
20558
+ exc,
20559
+ )
20560
+ return None
20561
+ else:
20562
+ return root
20563
+
20564
+ # end method definition
20565
+
20566
+ def get_document_template_names(self, workspace_id: int, root: ET.Element | None = None) -> list[str] | None:
20567
+ """Get the list of available template names from a workspace.
20568
+
20569
+ Args:
20570
+ workspace_id (int):
20571
+ The ID of the workspace to generate the document from.
20572
+ root (ET.Element | None, optional):
20573
+ The XML Element with the payload to initiate a document generation.
20574
+
20575
+ Returns:
20576
+ list[str] | None:
20577
+ A list of template names available in the workspace, or None if an error occurred.
20578
+
20579
+ """
20580
+
20581
+ if root is None:
20582
+ root = self._get_document_template_raw(workspace_id=workspace_id)
20583
+ if root is None:
20584
+ self.logger.error(
20585
+ "Cannot get document templates for workspace with ID -> %d",
20586
+ workspace_id,
20587
+ )
20588
+ return None
20589
+ template_names = [item.text for item in root.findall("startup/processing/templates/template")]
20590
+
20591
+ return template_names
20592
+
20593
+ # end method definition
20594
+
20595
+ def get_document_template(
20596
+ self, workspace_id: int, template_name: str, input_values: dict | None = None
20597
+ ) -> str | None:
20598
+ """Get the template XML payload from a workspace and a given template name.
20599
+
20600
+ Args:
20601
+ workspace_id (int):
20602
+ The ID of the workspace to generate the document from.
20603
+ template_name (str):
20604
+ The name of the template to use for document generation.
20605
+ input_values (dict | None, optional):
20606
+ A dictionary with input values to replace in the template.
20607
+
20608
+ Returns:
20609
+ str | None:
20610
+ The XML string with the payload to initiate a document generation, or None if an error occurred.
20611
+
20612
+ """
20613
+
20614
+ root = self._get_document_template_raw(workspace_id=workspace_id)
20615
+ if root is None:
20616
+ self.logger.error(
20617
+ "Cannot get document template for workspace with ID -> %d",
20618
+ workspace_id,
20619
+ )
20620
+ return None
20621
+
20622
+ template_names = self.get_document_template_names(workspace_id=workspace_id, root=root)
20623
+
20624
+ if template_name not in template_names:
20625
+ self.logger.error(
20626
+ "Template name -> '%s' not found in workspace with ID -> %d! Available templates are: %s",
20627
+ template_name,
20628
+ workspace_id,
20629
+ ", ".join(template_names),
20630
+ )
20631
+ return None
20632
+
20633
+ # remove startup/processing
20634
+ startup = root.find("startup")
20635
+ # Find the existing application element and update its sysid attribute
20636
+ application = startup.find("application")
20637
+ application.set("sysid", "3adfd3a4-718f-4b9c-ac93-72efbcdf17f1")
20638
+
20639
+ processing = startup.find("processing")
20640
+
20641
+ # Clear processing information
20642
+ processing.clear()
20643
+
20644
+ modus = ET.SubElement(processing, "modus")
20645
+ modus.text = "local"
20646
+ editor = ET.SubElement(processing, "editor")
20647
+ editor.text = "false"
20648
+ template = ET.SubElement(processing, "template", type="Name")
20649
+ template.text = template_name
20650
+ channel = ET.SubElement(processing, "channel")
20651
+ channel.text = "save"
20652
+
20653
+ # Add static query information for userId and asOfDate
20654
+ if input_values:
20655
+ query = ET.SubElement(startup, "query", type="value")
20656
+ input_element = ET.SubElement(query, "input")
20657
+
20658
+ for column, value in input_values.items():
20659
+ value_element = ET.SubElement(input_element, "value", column=column)
20660
+ value_element.text = value
20661
+
20662
+ payload = ET.tostring(root, encoding="utf8").decode("utf8")
20663
+
20664
+ return payload