pyxecm 3.0.0__py3-none-any.whl → 3.1.0__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 (53) 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 +870 -67
  9. pyxecm/otcs.py +1567 -280
  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.1.0.dist-info/METADATA +127 -0
  15. pyxecm-3.1.0.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 +164 -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 +98 -48
  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 +501 -20
  28. pyxecm_customizer/m365.py +45 -44
  29. pyxecm_customizer/payload.py +1684 -1159
  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.0.dist-info/METADATA +0 -48
  37. pyxecm-3.0.0.dist-info/RECORD +0 -96
  38. pyxecm_api/agents/__init__.py +0 -7
  39. pyxecm_api/agents/app.py +0 -13
  40. pyxecm_api/agents/functions.py +0 -119
  41. pyxecm_api/agents/models.py +0 -10
  42. pyxecm_api/agents/otcm_knowledgegraph/__init__.py +0 -1
  43. pyxecm_api/agents/otcm_knowledgegraph/functions.py +0 -85
  44. pyxecm_api/agents/otcm_knowledgegraph/models.py +0 -61
  45. pyxecm_api/agents/otcm_knowledgegraph/router.py +0 -74
  46. pyxecm_api/agents/otcm_user_agent/__init__.py +0 -1
  47. pyxecm_api/agents/otcm_user_agent/models.py +0 -20
  48. pyxecm_api/agents/otcm_user_agent/router.py +0 -65
  49. pyxecm_api/agents/otcm_workspace_agent/__init__.py +0 -1
  50. pyxecm_api/agents/otcm_workspace_agent/models.py +0 -40
  51. pyxecm_api/agents/otcm_workspace_agent/router.py +0 -200
  52. {pyxecm-3.0.0.dist-info → pyxecm-3.1.0.dist-info}/WHEEL +0 -0
  53. {pyxecm-3.0.0.dist-info → pyxecm-3.1.0.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
@@ -567,6 +546,34 @@ class OTCS:
567
546
 
568
547
  # end method definition
569
548
 
549
+ def otcs_ticket_hashed(self) -> str | None:
550
+ """Return the hashed OTCS ticket.
551
+
552
+ Returns:
553
+ str | None:
554
+ The hashed OTCS ticket (which may be None).
555
+
556
+ """
557
+
558
+ if not self._otcs_ticket:
559
+ return None
560
+
561
+ # Encode the input string before hashing
562
+ encoded_string = self._otcs_ticket.encode("utf-8")
563
+
564
+ # Create a new SHA-512 hash object
565
+ sha512 = hashlib.sha512()
566
+
567
+ # Update the hash object with the input string
568
+ sha512.update(encoded_string)
569
+
570
+ # Get the hexadecimal representation of the hash
571
+ hashed_output = sha512.hexdigest()
572
+
573
+ return hashed_output
574
+
575
+ # end method definition
576
+
570
577
  def set_otcs_ticket(self, ticket: str) -> None:
571
578
  """Set the OTCS ticket.
572
579
 
@@ -593,6 +600,19 @@ class OTCS:
593
600
 
594
601
  # end method definition
595
602
 
603
+ def set_otds_token(self, token: str) -> None:
604
+ """Set the OTDS token.
605
+
606
+ Args:
607
+ token (str):
608
+ The new OTDS token.
609
+
610
+ """
611
+
612
+ self._otds_token = token
613
+
614
+ # end method definition
615
+
596
616
  def credentials(self) -> dict:
597
617
  """Get credentials (username + password).
598
618
 
@@ -903,7 +923,7 @@ class OTCS:
903
923
  data: dict | None = None,
904
924
  json_data: dict | None = None,
905
925
  files: dict | None = None,
906
- timeout: int | None = REQUEST_TIMEOUT,
926
+ timeout: float | None = REQUEST_TIMEOUT,
907
927
  show_error: bool = True,
908
928
  show_warning: bool = False,
909
929
  warning_message: str = "",
@@ -931,7 +951,7 @@ class OTCS:
931
951
  Dictionary of {"name": file-tuple} for multipart encoding upload.
932
952
  The file-tuple can be a 2-tuple ("filename", fileobj) or a 3-tuple
933
953
  ("filename", fileobj, "content_type").
934
- timeout (int | None, optional):
954
+ timeout (float | None, optional):
935
955
  Timeout for the request in seconds. Defaults to REQUEST_TIMEOUT.
936
956
  show_error (bool, optional):
937
957
  Whether or not an error should be logged in case of a failed REST call.
@@ -1106,8 +1126,11 @@ class OTCS:
1106
1126
  except requests.exceptions.ConnectionError:
1107
1127
  if retries <= max_retries:
1108
1128
  self.logger.warning(
1109
- "Connection error. Retrying in %s seconds...",
1110
- str(REQUEST_RETRY_DELAY),
1129
+ "Connection error (%s)! Retrying in %d seconds... %d/%d",
1130
+ url,
1131
+ REQUEST_RETRY_DELAY,
1132
+ retries,
1133
+ max_retries,
1111
1134
  )
1112
1135
  retries += 1
1113
1136
 
@@ -1226,9 +1249,7 @@ class OTCS:
1226
1249
 
1227
1250
  """
1228
1251
 
1229
- if not response:
1230
- return None
1231
- if "results" not in response:
1252
+ if not response or "results" not in response:
1232
1253
  return None
1233
1254
 
1234
1255
  results = response["results"]
@@ -1408,7 +1429,7 @@ class OTCS:
1408
1429
 
1409
1430
  def get_result_value(
1410
1431
  self,
1411
- response: dict,
1432
+ response: dict | None,
1412
1433
  key: str,
1413
1434
  index: int = 0,
1414
1435
  property_name: str = "properties",
@@ -1421,8 +1442,8 @@ class OTCS:
1421
1442
  developer.opentext.com.
1422
1443
 
1423
1444
  Args:
1424
- response (dict):
1425
- REST API response object.
1445
+ response (dict | None):
1446
+ REST API response object. None is also handled.
1426
1447
  key (str):
1427
1448
  Key to find (e.g., "id", "name").
1428
1449
  index (int, optional):
@@ -1435,7 +1456,7 @@ class OTCS:
1435
1456
  Whether an error or just a warning should be logged.
1436
1457
 
1437
1458
  Returns:
1438
- str:
1459
+ str | None:
1439
1460
  Value of the item with the given key, or None if no value is found.
1440
1461
 
1441
1462
  """
@@ -1521,16 +1542,22 @@ class OTCS:
1521
1542
  data = results[index]["data"]
1522
1543
  if isinstance(data, dict):
1523
1544
  # data is a dict - we don't need index value:
1524
- properties = data[property_name]
1545
+ properties = data.get(property_name)
1525
1546
  elif isinstance(data, list):
1526
1547
  # data is a list - this has typically just one item, so we use 0 as index
1527
- properties = data[0][property_name]
1548
+ properties = data[0].get(property_name)
1528
1549
  else:
1529
1550
  self.logger.error(
1530
1551
  "Data needs to be a list or dict but it is -> %s",
1531
1552
  str(type(data)),
1532
1553
  )
1533
1554
  return None
1555
+ if not properties:
1556
+ self.logger.error(
1557
+ "No properties found in data -> %s",
1558
+ str(data),
1559
+ )
1560
+ return None
1534
1561
  if key not in properties:
1535
1562
  if show_error:
1536
1563
  self.logger.error("Key -> '%s' is not in result properties!", key)
@@ -1669,8 +1696,8 @@ class OTCS:
1669
1696
  Defaults to "data".
1670
1697
 
1671
1698
  Returns:
1672
- list | None:
1673
- Value list of the item with the given key, or None if no value is found.
1699
+ iter:
1700
+ Iterator object for iterating through the values.
1674
1701
 
1675
1702
  """
1676
1703
 
@@ -1835,6 +1862,31 @@ class OTCS:
1835
1862
 
1836
1863
  request_url = self.config()["authenticationUrl"]
1837
1864
 
1865
+ if self._otds_token and not revalidate:
1866
+ self.logger.debug(
1867
+ "Requesting OTCS ticket with existing OTDS token; calling -> %s",
1868
+ request_url,
1869
+ )
1870
+ # Add the OTDS token to the request headers:
1871
+ request_header = REQUEST_FORM_HEADERS | {"Authorization": f"Bearer {self._otds_token}"}
1872
+
1873
+ try:
1874
+ response = requests.get(
1875
+ url=request_url,
1876
+ headers=request_header,
1877
+ timeout=10,
1878
+ )
1879
+ if response.ok:
1880
+ # read the ticket from the response header:
1881
+ otcs_ticket = response.headers.get("OTCSTicket")
1882
+
1883
+ except requests.exceptions.RequestException as exception:
1884
+ self.logger.warning(
1885
+ "Unable to connect to -> %s; error -> %s",
1886
+ request_url,
1887
+ str(exception),
1888
+ )
1889
+
1838
1890
  if self._otds_ticket and not revalidate:
1839
1891
  self.logger.debug(
1840
1892
  "Requesting OTCS ticket with existing OTDS ticket; calling -> %s",
@@ -2138,7 +2190,8 @@ class OTCS:
2138
2190
  """Apply Content Server administration settings from XML file.
2139
2191
 
2140
2192
  Args:
2141
- xml_file_path (str): name + path of the XML settings file
2193
+ xml_file_path (str):
2194
+ The fully qualified path to the XML settings file.
2142
2195
 
2143
2196
  Returns:
2144
2197
  dict | None:
@@ -2178,7 +2231,7 @@ class OTCS:
2178
2231
  headers=request_header,
2179
2232
  files=llconfig_file,
2180
2233
  timeout=None,
2181
- success_message="Admin settings in file -> '{}' have been applied".format(
2234
+ success_message="Admin settings in file -> '{}' have been applied.".format(
2182
2235
  xml_file_path,
2183
2236
  ),
2184
2237
  failure_message="Failed to import settings file -> '{}'".format(
@@ -2733,7 +2786,13 @@ class OTCS:
2733
2786
 
2734
2787
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_current_user")
2735
2788
  def get_current_user(self) -> dict | None:
2736
- """Get the current authenticated user."""
2789
+ """Get the current authenticated user.
2790
+
2791
+ Returns:
2792
+ dict | None:
2793
+ Information for the current (authenticated) user.
2794
+
2795
+ """
2737
2796
 
2738
2797
  request_url = self.config()["authenticationUrl"]
2739
2798
 
@@ -2749,7 +2808,7 @@ class OTCS:
2749
2808
  method="GET",
2750
2809
  headers=request_header,
2751
2810
  timeout=None,
2752
- failure_message="Failed to get current user!",
2811
+ failure_message="Failed to get current user",
2753
2812
  )
2754
2813
 
2755
2814
  # end method definition
@@ -2764,6 +2823,7 @@ class OTCS:
2764
2823
  email: str,
2765
2824
  title: str,
2766
2825
  base_group: int,
2826
+ phone: str = "",
2767
2827
  privileges: list | None = None,
2768
2828
  user_type: int = 0,
2769
2829
  ) -> dict | None:
@@ -2784,7 +2844,9 @@ class OTCS:
2784
2844
  The title of the user.
2785
2845
  base_group (int):
2786
2846
  The base group id of the user (e.g. department)
2787
- privileges (list, optional):
2847
+ phone (str, optional):
2848
+ The business phone number of the user.
2849
+ privileges (list | None, optional):
2788
2850
  Possible values are Login, Public Access, Content Manager,
2789
2851
  Modify Users, Modify Groups, User Admin Rights,
2790
2852
  Grant Discovery, System Admin Rights
@@ -2808,6 +2870,7 @@ class OTCS:
2808
2870
  "first_name": first_name,
2809
2871
  "last_name": last_name,
2810
2872
  "business_email": email,
2873
+ "business_phone": phone,
2811
2874
  "title": title,
2812
2875
  "group_id": base_group,
2813
2876
  "privilege_login": ("Login" in privileges),
@@ -2987,11 +3050,14 @@ class OTCS:
2987
3050
  """Update a user with a profile photo (which must be an existing node).
2988
3051
 
2989
3052
  Args:
2990
- user_id (int): The ID of the user.
2991
- photo_id (int): The node ID of the photo.
3053
+ user_id (int):
3054
+ The ID of the user.
3055
+ photo_id (int):
3056
+ The node ID of the photo.
2992
3057
 
2993
3058
  Returns:
2994
- dict | None: Node information or None if photo node is not found.
3059
+ dict | None:
3060
+ Node information or None if photo node is not found.
2995
3061
 
2996
3062
  """
2997
3063
 
@@ -3027,10 +3093,12 @@ class OTCS:
3027
3093
  that was introduced with version 23.4.
3028
3094
 
3029
3095
  Args:
3030
- user_name (str): user to test (login name)
3096
+ user_name (str):
3097
+ The user to test (login name) for proxy.
3031
3098
 
3032
3099
  Returns:
3033
- bool: True is user is proxy of current user. False if not.
3100
+ bool:
3101
+ True is user is proxy of current user. False if not.
3034
3102
 
3035
3103
  """
3036
3104
 
@@ -3956,7 +4024,7 @@ class OTCS:
3956
4024
  method="GET",
3957
4025
  headers=request_header,
3958
4026
  timeout=REQUEST_TIMEOUT,
3959
- failure_message="Failed to get system usage privileges.",
4027
+ failure_message="Failed to get system usage privileges",
3960
4028
  )
3961
4029
 
3962
4030
  if response:
@@ -4160,7 +4228,7 @@ class OTCS:
4160
4228
  method="GET",
4161
4229
  headers=request_header,
4162
4230
  timeout=REQUEST_TIMEOUT,
4163
- failure_message="Failed to get system usage privileges.",
4231
+ failure_message="Failed to get system usage privileges",
4164
4232
  )
4165
4233
 
4166
4234
  if response:
@@ -4306,9 +4374,9 @@ class OTCS:
4306
4374
  def get_node(
4307
4375
  self,
4308
4376
  node_id: int,
4309
- fields: (str | list) = "properties", # per default we just get the most important information
4377
+ fields: str | list = "properties", # per default we just get the most important information
4310
4378
  metadata: bool = False,
4311
- timeout: int = REQUEST_TIMEOUT,
4379
+ timeout: float = REQUEST_TIMEOUT,
4312
4380
  ) -> dict | None:
4313
4381
  """Get a node based on the node ID.
4314
4382
 
@@ -4335,7 +4403,7 @@ class OTCS:
4335
4403
  The metadata will be returned under `results.metadata`, `metadata_map`,
4336
4404
  and `metadata_order`.
4337
4405
  Defaults to False.
4338
- timeout (int, optional):
4406
+ timeout (float, optional):
4339
4407
  Timeout for the request in seconds. Defaults to `REQUEST_TIMEOUT`.
4340
4408
 
4341
4409
  Returns:
@@ -5282,7 +5350,7 @@ class OTCS:
5282
5350
  # end method definition
5283
5351
 
5284
5352
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="lookup_node")
5285
- def lookup_node(
5353
+ def lookup_nodes(
5286
5354
  self,
5287
5355
  parent_node_id: int,
5288
5356
  category: str,
@@ -5290,7 +5358,7 @@ class OTCS:
5290
5358
  value: str,
5291
5359
  attribute_set: str | None = None,
5292
5360
  ) -> dict | None:
5293
- """Lookup the node under a parent node that has a specified value in a category attribute.
5361
+ """Lookup nodes under a parent node that have a specified value in a category attribute.
5294
5362
 
5295
5363
  Args:
5296
5364
  parent_node_id (int):
@@ -5306,10 +5374,12 @@ class OTCS:
5306
5374
 
5307
5375
  Returns:
5308
5376
  dict | None:
5309
- Node wrapped in dictionary with "results" key or None if the REST API fails.
5377
+ Node(s) wrapped in dictionary with "results" key or None if the REST API fails.
5310
5378
 
5311
5379
  """
5312
5380
 
5381
+ results = {"results": []}
5382
+
5313
5383
  # get_subnodes_iterator() returns a python generator that we use for iterating over all nodes
5314
5384
  # in an efficient way avoiding to retrieve all nodes at once (which could be a large number):
5315
5385
  for node in self.get_subnodes_iterator(
@@ -5392,25 +5462,29 @@ class OTCS:
5392
5462
  if value in attribute_value:
5393
5463
  # Create a "results" dict that is compatible with normal REST calls
5394
5464
  # to not break get_result_value() method that may be called on the result:
5395
- return {"results": node}
5465
+ results["results"].append(node)
5396
5466
  elif value == attribute_value:
5397
5467
  # Create a results dict that is compatible with normal REST calls
5398
5468
  # to not break get_result_value() method that may be called on the result:
5399
- return {"results": node}
5469
+ results["results"].append(node)
5470
+ # end if set_key
5400
5471
  else:
5401
5472
  key = prefix + attribute_id
5402
5473
  attribute_value = cat_data.get(key)
5403
5474
  if not attribute_value:
5404
- break
5475
+ continue
5476
+ # Is it a multi-value attribute (i.e. a list of values)?
5405
5477
  if isinstance(attribute_value, list):
5406
5478
  if value in attribute_value:
5407
5479
  # Create a "results" dict that is compatible with normal REST calls
5408
5480
  # to not break get_result_value() method that may be called on the result:
5409
- return {"results": node}
5481
+ results["results"].append(node)
5482
+ # If not a multi-value attribute, check for equality:
5410
5483
  elif value == attribute_value:
5411
5484
  # Create a results dict that is compatible with normal REST calls
5412
5485
  # to not break get_result_value() method that may be called on the result:
5413
- return {"results": node}
5486
+ results["results"].append(node)
5487
+ # end if set_key else
5414
5488
  # end for cat_data, cat_schema in zip(data, schema)
5415
5489
  # end for node in nodes
5416
5490
 
@@ -5422,7 +5496,7 @@ class OTCS:
5422
5496
  parent_node_id,
5423
5497
  )
5424
5498
 
5425
- return {"results": []}
5499
+ return results if results["results"] else None
5426
5500
 
5427
5501
  # end method definition
5428
5502
 
@@ -6322,14 +6396,14 @@ class OTCS:
6322
6396
  def get_volume(
6323
6397
  self,
6324
6398
  volume_type: int,
6325
- timeout: int = REQUEST_TIMEOUT,
6399
+ timeout: float = REQUEST_TIMEOUT,
6326
6400
  ) -> dict | None:
6327
6401
  """Get Volume information based on the volume type ID.
6328
6402
 
6329
6403
  Args:
6330
6404
  volume_type (int):
6331
6405
  The ID of the volume type.
6332
- timeout (int, optional):
6406
+ timeout (float | None, optional):
6333
6407
  The timeout for the request in seconds.
6334
6408
 
6335
6409
  Returns:
@@ -6592,6 +6666,7 @@ class OTCS:
6592
6666
  external_modify_date: str | None = None,
6593
6667
  external_create_date: str | None = None,
6594
6668
  extract_zip: bool = False,
6669
+ replace_existing: bool = False,
6595
6670
  show_error: bool = True,
6596
6671
  ) -> dict | None:
6597
6672
  """Fetch a file from a URL or local filesystem and uploads it to a OTCS parent.
@@ -6633,6 +6708,9 @@ class OTCS:
6633
6708
  extract_zip (bool, optional):
6634
6709
  If True, automatically extract ZIP files and upload extracted directory. If False,
6635
6710
  upload the unchanged Zip file.
6711
+ replace_existing (bool, optional):
6712
+ If True, replaces an existing file with the same name in the target folder. If False,
6713
+ the upload will fail if a file with the same name already exists.
6636
6714
  show_error (bool, optional):
6637
6715
  If True, treats the upload failure as an error. If False, no error is shown (useful if the file already exists).
6638
6716
 
@@ -6683,8 +6761,7 @@ class OTCS:
6683
6761
  file_content = response.content
6684
6762
 
6685
6763
  # 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
-
6764
+ # it and then defer the upload to upload_directory_to_parent():
6688
6765
  elif os.path.exists(file_url) and (
6689
6766
  ((file_url.endswith(".zip") or mime_type == "application/x-zip-compressed") and extract_zip)
6690
6767
  or os.path.isdir(file_url)
@@ -6692,6 +6769,7 @@ class OTCS:
6692
6769
  return self.upload_directory_to_parent(
6693
6770
  parent_id=parent_id,
6694
6771
  file_path=file_url,
6772
+ replace_existing=replace_existing,
6695
6773
  )
6696
6774
 
6697
6775
  elif os.path.exists(file_url):
@@ -6786,7 +6864,7 @@ class OTCS:
6786
6864
  # end method definition
6787
6865
 
6788
6866
  @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:
6867
+ def upload_directory_to_parent(self, parent_id: int, file_path: str, replace_existing: bool = True) -> dict | None:
6790
6868
  """Upload a directory or an uncompressed zip file to Content Server.
6791
6869
 
6792
6870
  IMPORTANT: if the path ends in a file then we assume it is a ZIP file!
@@ -6796,6 +6874,8 @@ class OTCS:
6796
6874
  ID of the parent in Content Server.
6797
6875
  file_path (str):
6798
6876
  File system path to the directory or zip file.
6877
+ replace_existing (bool, optional):
6878
+ If True, existing files are replaced by uploading a new version.
6799
6879
 
6800
6880
  Returns:
6801
6881
  dict | None:
@@ -6893,21 +6973,25 @@ class OTCS:
6893
6973
  response = self.upload_directory_to_parent(
6894
6974
  parent_id=current_parent_id,
6895
6975
  file_path=full_file_path,
6976
+ replace_existing=replace_existing,
6896
6977
  )
6897
6978
  if response and not first_response:
6898
6979
  first_response = response.copy()
6899
6980
  continue
6981
+ # Check if the file already exists:
6900
6982
  response = self.get_node_by_parent_and_name(
6901
6983
  parent_id=current_parent_id,
6902
6984
  name=file_name,
6903
6985
  )
6904
6986
  if not response or not response["results"]:
6987
+ # File does not yet exist - upload new document:
6905
6988
  response = self.upload_file_to_parent(
6906
6989
  parent_id=current_parent_id,
6907
6990
  file_url=full_file_path,
6908
6991
  file_name=file_name,
6909
6992
  )
6910
- else:
6993
+ elif replace_existing:
6994
+ # Document does already exist - upload a new version if replace existing is requested:
6911
6995
  existing_document_id = self.get_result_value(
6912
6996
  response=response,
6913
6997
  key="id",
@@ -7429,6 +7513,8 @@ class OTCS:
7429
7513
  node_id: int,
7430
7514
  file_path: str,
7431
7515
  version_number: str | int = "",
7516
+ chunk_size: int = 8192,
7517
+ overwrite: bool = True,
7432
7518
  ) -> bool:
7433
7519
  """Download a document from OTCS to local file system.
7434
7520
 
@@ -7440,6 +7526,12 @@ class OTCS:
7440
7526
  version_number (str | int, optional):
7441
7527
  The version of the document to download.
7442
7528
  If version = "" then download the latest version.
7529
+ chunk_size (int, optional):
7530
+ The chunk size to use when downloading the document in bytes.
7531
+ Default is 8192 bytes.
7532
+ overwrite (bool, optional):
7533
+ If True, overwrite the file if it already exists. If False, do not overwrite
7534
+ and return False if the file already exists.
7443
7535
 
7444
7536
  Returns:
7445
7537
  bool:
@@ -7449,24 +7541,30 @@ class OTCS:
7449
7541
  """
7450
7542
 
7451
7543
  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"
7544
+ # we retrieve the latest version - using V1 REST API. V2 has issues here.:
7545
+ # request_url = self.config()["nodesUrlv2"] + "/" + str(node_id) + "/content"
7546
+ request_url = self.config()["nodesUrl"] + "/" + str(node_id) + "/content"
7547
+ self.logger.debug(
7548
+ "Download document with node ID -> %d (latest version); calling -> %s",
7549
+ node_id,
7550
+ request_url,
7551
+ )
7552
+ else:
7553
+ # we retrieve the given version - using V1 REST API. V2 has issues here.:
7554
+ # request_url = (
7555
+ # self.config()["nodesUrlv2"] + "/" + str(node_id) + "/versions/" + str(version_number) + "/content"
7556
+ # )
7557
+ request_url = (
7558
+ self.config()["nodesUrl"] + "/" + str(node_id) + "/versions/" + str(version_number) + "/content"
7559
+ )
7560
+ self.logger.debug(
7561
+ "Download document with node ID -> %d and version number -> %d; calling -> %s",
7562
+ node_id,
7563
+ version_number,
7564
+ request_url,
7565
+ )
7462
7566
  request_header = self.request_download_header()
7463
7567
 
7464
- self.logger.debug(
7465
- "Download document with node ID -> %d; calling -> %s",
7466
- node_id,
7467
- request_url,
7468
- )
7469
-
7470
7568
  response = self.do_request(
7471
7569
  url=request_url,
7472
7570
  method="GET",
@@ -7482,6 +7580,18 @@ class OTCS:
7482
7580
  if response is None:
7483
7581
  return False
7484
7582
 
7583
+ total_size = int(response.headers["Content-Length"]) if "Content-Length" in response.headers else None
7584
+
7585
+ content_encoding = response.headers.get("Content-Encoding", "").lower()
7586
+ is_compressed = content_encoding in ("gzip", "deflate", "br")
7587
+
7588
+ if os.path.exists(file_path) and not overwrite:
7589
+ self.logger.warning(
7590
+ "File -> '%s' already exists and overwrite is set to False, not downloading document.",
7591
+ file_path,
7592
+ )
7593
+ return False
7594
+
7485
7595
  directory = os.path.dirname(file_path)
7486
7596
  if not os.path.exists(directory):
7487
7597
  self.logger.info(
@@ -7490,12 +7600,28 @@ class OTCS:
7490
7600
  )
7491
7601
  os.makedirs(directory)
7492
7602
 
7603
+ bytes_downloaded = 0
7493
7604
  try:
7494
7605
  with open(file_path, "wb") as download_file:
7495
- download_file.writelines(response.iter_content(chunk_size=1024))
7496
- except Exception:
7606
+ for chunk in response.iter_content(chunk_size=chunk_size):
7607
+ if chunk:
7608
+ download_file.write(chunk)
7609
+ bytes_downloaded += len(chunk)
7610
+
7611
+ except Exception as e:
7612
+ self.logger.error(
7613
+ "Error while writing content to file -> %s after %d bytes downloaded; error -> %s",
7614
+ file_path,
7615
+ bytes_downloaded,
7616
+ str(e),
7617
+ )
7618
+ return False
7619
+
7620
+ if total_size and not is_compressed and bytes_downloaded != total_size:
7497
7621
  self.logger.error(
7498
- "Error while writing content to file -> %s",
7622
+ "Downloaded size (%d bytes) does not match expected size (%d bytes) for file -> '%s'",
7623
+ bytes_downloaded,
7624
+ total_size,
7499
7625
  file_path,
7500
7626
  )
7501
7627
  return False
@@ -7592,9 +7718,11 @@ class OTCS:
7592
7718
  search_term: str,
7593
7719
  look_for: str = "complexQuery",
7594
7720
  modifier: str = "",
7721
+ within: str = "all",
7595
7722
  slice_id: int = 0,
7596
7723
  query_id: int = 0,
7597
7724
  template_id: int = 0,
7725
+ location_id: int | None = None,
7598
7726
  limit: int = 100,
7599
7727
  page: int = 1,
7600
7728
  ) -> dict | None:
@@ -7619,12 +7747,20 @@ class OTCS:
7619
7747
  - 'wordendswith'
7620
7748
  If not specified or any value other than these options is given,
7621
7749
  it is ignored.
7750
+ within (str, optional):
7751
+ The scope of the search. Possible values are:
7752
+ - 'all': search in content and in metadata (default)
7753
+ - 'content': search only in document content
7754
+ - 'metadata': search only in item metadata
7622
7755
  slice_id (int, optional):
7623
7756
  The ID of an existing search slice.
7624
7757
  query_id (int, optional):
7625
7758
  The ID of a saved search query.
7626
7759
  template_id (int, optional):
7627
7760
  The ID of a saved search template.
7761
+ location_id (int | None, optional):
7762
+ The ID of a folder or workspace to start a search from here.
7763
+ None = unrestricted search (default).
7628
7764
  limit (int, optional):
7629
7765
  The maximum number of results to return. Default is 100.
7630
7766
  page (int, optional):
@@ -7809,6 +7945,7 @@ class OTCS:
7809
7945
  search_post_body = {
7810
7946
  "where": search_term,
7811
7947
  "lookfor": look_for,
7948
+ "within": within,
7812
7949
  "page": page,
7813
7950
  "limit": limit,
7814
7951
  }
@@ -7821,6 +7958,8 @@ class OTCS:
7821
7958
  search_post_body["query_id"] = query_id
7822
7959
  if template_id > 0:
7823
7960
  search_post_body["template_id"] = template_id
7961
+ if location_id is not None:
7962
+ search_post_body["location_id1"] = location_id
7824
7963
 
7825
7964
  request_url = self.config()["searchUrl"]
7826
7965
  request_header = self.request_form_header()
@@ -7847,10 +7986,13 @@ class OTCS:
7847
7986
  search_term: str,
7848
7987
  look_for: str = "complexQuery",
7849
7988
  modifier: str = "",
7989
+ within: str = "all",
7850
7990
  slice_id: int = 0,
7851
7991
  query_id: int = 0,
7852
7992
  template_id: int = 0,
7993
+ location_id: int | None = None,
7853
7994
  page_size: int = 100,
7995
+ limit: int | None = None,
7854
7996
  ) -> iter:
7855
7997
  """Get an iterator object to traverse all search results for a given search.
7856
7998
 
@@ -7878,19 +8020,31 @@ class OTCS:
7878
8020
  Defines a modifier for the search. Possible values are:
7879
8021
  - 'synonymsof', 'relatedto', 'soundslike', 'wordbeginswith', 'wordendswith'.
7880
8022
  If not specified or any value other than these options is given, it is ignored.
8023
+ within (str, optional):
8024
+ The scope of the search. Possible values are:
8025
+ - 'all': search in content and in metadata (default)
8026
+ - 'content': search only in document content
8027
+ - 'metadata': search only in item metadata
7881
8028
  slice_id (int, optional):
7882
8029
  The ID of an existing search slice.
7883
8030
  query_id (int, optional):
7884
8031
  The ID of a saved search query.
7885
8032
  template_id (int, optional):
7886
8033
  The ID of a saved search template.
8034
+ location_id (int | None, optional):
8035
+ The ID of a folder or workspace to start a search from here.
8036
+ None = unrestricted search (default).
7887
8037
  page_size (int, optional):
7888
8038
  The maximum number of results to return. Default is 100.
7889
- For the iterator this ois basically the chunk size.
8039
+ For the iterator this is basically the chunk size.
8040
+ limit (int | None = None), optional):
8041
+ The maximum number of results to return in total.
8042
+ If None (default) all results are returned.
8043
+ If a number is provided only up to this number of results is returned.
7890
8044
 
7891
8045
  Returns:
7892
- dict | None:
7893
- The search response as a dictionary if successful, or None if the search fails.
8046
+ iter:
8047
+ The search response iterator object.
7894
8048
 
7895
8049
  """
7896
8050
 
@@ -7899,9 +8053,11 @@ class OTCS:
7899
8053
  search_term=search_term,
7900
8054
  look_for=look_for,
7901
8055
  modifier=modifier,
8056
+ within=within,
7902
8057
  slice_id=slice_id,
7903
8058
  query_id=query_id,
7904
8059
  template_id=template_id,
8060
+ location_id=location_id,
7905
8061
  limit=1,
7906
8062
  page=1,
7907
8063
  )
@@ -7912,6 +8068,9 @@ class OTCS:
7912
8068
  return
7913
8069
 
7914
8070
  number_of_results = response["collection"]["paging"]["total_count"]
8071
+ if limit and number_of_results > limit:
8072
+ number_of_results = limit
8073
+
7915
8074
  if not number_of_results:
7916
8075
  self.logger.debug(
7917
8076
  "Search -> '%s' does not have results! Cannot iterate over results.",
@@ -7934,9 +8093,11 @@ class OTCS:
7934
8093
  search_term=search_term,
7935
8094
  look_for=look_for,
7936
8095
  modifier=modifier,
8096
+ within=within,
7937
8097
  slice_id=slice_id,
7938
8098
  query_id=query_id,
7939
8099
  template_id=template_id,
8100
+ location_id=location_id,
7940
8101
  limit=page_size,
7941
8102
  page=page,
7942
8103
  )
@@ -7980,7 +8141,7 @@ class OTCS:
7980
8141
  request_header = self.cookie()
7981
8142
 
7982
8143
  self.logger.debug(
7983
- "Get external system connection -> %s; calling -> %s",
8144
+ "Get external system connection -> '%s'; calling -> %s",
7984
8145
  connection_name,
7985
8146
  request_url,
7986
8147
  )
@@ -8170,7 +8331,7 @@ class OTCS:
8170
8331
  # end method definition
8171
8332
 
8172
8333
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="deploy_workbench")
8173
- def deploy_workbench(self, workbench_id: int) -> dict | None:
8334
+ def deploy_workbench(self, workbench_id: int) -> tuple[dict | None, int]:
8174
8335
  """Deploy an existing Workbench.
8175
8336
 
8176
8337
  Args:
@@ -8180,6 +8341,8 @@ class OTCS:
8180
8341
  Returns:
8181
8342
  dict | None:
8182
8343
  The deploy response or None if the deployment fails.
8344
+ int:
8345
+ Error count. Should be 0 if fully successful.
8183
8346
 
8184
8347
  Example response:
8185
8348
  {
@@ -8227,8 +8390,12 @@ class OTCS:
8227
8390
  ),
8228
8391
  )
8229
8392
 
8393
+ # Transport packages canalso partly fail to deploy.
8394
+ # For such cases we determine the number of errors.
8395
+ error_count = 0
8396
+
8230
8397
  if not response or "results" not in response:
8231
- return None
8398
+ return (None, 0)
8232
8399
 
8233
8400
  try:
8234
8401
  error_count = response["results"]["data"]["status"]["error_count"]
@@ -8239,7 +8406,7 @@ class OTCS:
8239
8406
  else:
8240
8407
  success_count = response["results"]["data"]["status"]["success_count"]
8241
8408
  self.logger.info(
8242
- "Transport successfully deployed %d workbench items",
8409
+ "Transport successfully deployed %d workbench items.",
8243
8410
  success_count,
8244
8411
  )
8245
8412
 
@@ -8254,7 +8421,7 @@ class OTCS:
8254
8421
  except Exception as e:
8255
8422
  self.logger.debug(str(e))
8256
8423
 
8257
- return response
8424
+ return (response, error_count)
8258
8425
 
8259
8426
  # end method definition
8260
8427
 
@@ -8309,11 +8476,18 @@ class OTCS:
8309
8476
  if extractions is None:
8310
8477
  extractions = []
8311
8478
 
8479
+ while not self.is_ready():
8480
+ self.logger.info(
8481
+ "OTCS is not ready. Cannot deploy transport -> '%s' to OTCS. Waiting 30 seconds and retry...",
8482
+ package_name,
8483
+ )
8484
+ time.sleep(30)
8485
+
8312
8486
  # Preparation: get volume IDs for Transport Warehouse (root volume and Transport Packages)
8313
8487
  response = self.get_volume(volume_type=self.VOLUME_TYPE_TRANSPORT_WAREHOUSE)
8314
8488
  transport_root_volume_id = self.get_result_value(response=response, key="id")
8315
8489
  if not transport_root_volume_id:
8316
- self.logger.error("Failed to retrieve transport root volume")
8490
+ self.logger.error("Failed to retrieve transport root volume!")
8317
8491
  return None
8318
8492
  self.logger.debug(
8319
8493
  "Transport root volume ID -> %d",
@@ -8326,7 +8500,7 @@ class OTCS:
8326
8500
  )
8327
8501
  transport_package_volume_id = self.get_result_value(response=response, key="id")
8328
8502
  if not transport_package_volume_id:
8329
- self.logger.error("Failed to retrieve transport package volume")
8503
+ self.logger.error("Failed to retrieve transport package volume!")
8330
8504
  return None
8331
8505
  self.logger.debug(
8332
8506
  "Transport package volume ID -> %d",
@@ -8345,20 +8519,20 @@ class OTCS:
8345
8519
  package_id = self.get_result_value(response=response, key="id")
8346
8520
  if package_id:
8347
8521
  self.logger.debug(
8348
- "Transport package -> '%s' does already exist; existing package ID -> %d",
8522
+ "Transport package -> '%s' does already exist; existing package ID -> %d.",
8349
8523
  package_name,
8350
8524
  package_id,
8351
8525
  )
8352
8526
  else:
8353
8527
  self.logger.debug(
8354
- "Transport package -> '%s' does not yet exist, loading from -> %s",
8528
+ "Transport package -> '%s' does not yet exist, loading from -> '%s'...",
8355
8529
  package_name,
8356
8530
  package_url,
8357
8531
  )
8358
8532
  # If we have string replacements configured execute them now:
8359
8533
  if replacements:
8360
8534
  self.logger.debug(
8361
- "Transport -> '%s' has replacements -> %s",
8535
+ "Transport -> '%s' has replacements -> %s.",
8362
8536
  package_name,
8363
8537
  str(replacements),
8364
8538
  )
@@ -8368,13 +8542,13 @@ class OTCS:
8368
8542
  )
8369
8543
  else:
8370
8544
  self.logger.debug(
8371
- "Transport -> '%s' has no replacements!",
8545
+ "Transport -> '%s' has no replacements.",
8372
8546
  package_name,
8373
8547
  )
8374
8548
  # If we have data extractions configured execute them now:
8375
8549
  if extractions:
8376
8550
  self.logger.debug(
8377
- "Transport -> '%s' has extractions -> %s",
8551
+ "Transport -> '%s' has extractions -> %s.",
8378
8552
  package_name,
8379
8553
  str(extractions),
8380
8554
  )
@@ -8383,7 +8557,7 @@ class OTCS:
8383
8557
  extractions=extractions,
8384
8558
  )
8385
8559
  else:
8386
- self.logger.debug("Transport -> '%s' has no extractions!", package_name)
8560
+ self.logger.debug("Transport -> '%s' has no extractions.", package_name)
8387
8561
 
8388
8562
  # Upload package to Transport Warehouse:
8389
8563
  response = self.upload_file_to_volume(
@@ -8395,7 +8569,7 @@ class OTCS:
8395
8569
  package_id = self.get_result_value(response=response, key="id")
8396
8570
  if not package_id:
8397
8571
  self.logger.error(
8398
- "Failed to upload transport package -> %s",
8572
+ "Failed to upload transport package -> '%s'!",
8399
8573
  package_url,
8400
8574
  )
8401
8575
  return None
@@ -8438,7 +8612,7 @@ class OTCS:
8438
8612
  workbench_id = self.get_result_value(response=response, key="id")
8439
8613
  if workbench_id:
8440
8614
  self.logger.debug(
8441
- "Workbench -> '%s' does already exist but is not successfully deployed; existing workbench ID -> %d",
8615
+ "Workbench -> '%s' does already exist but is not successfully deployed; existing workbench ID -> %d.",
8442
8616
  workbench_name,
8443
8617
  workbench_id,
8444
8618
  )
@@ -8454,14 +8628,14 @@ class OTCS:
8454
8628
  )
8455
8629
  return None
8456
8630
  self.logger.debug(
8457
- "Successfully created workbench -> '%s'; new workbench ID -> %d",
8631
+ "Successfully created workbench -> '%s'; new workbench ID -> %d.",
8458
8632
  workbench_name,
8459
8633
  workbench_id,
8460
8634
  )
8461
8635
 
8462
8636
  # Step 3: Unpack Transport Package to Workbench
8463
8637
  self.logger.debug(
8464
- "Unpack transport package -> '%s' (%d) to workbench -> '%s' (%d)",
8638
+ "Unpack transport package -> '%s' (%d) to workbench -> '%s' (%d)...",
8465
8639
  package_name,
8466
8640
  package_id,
8467
8641
  workbench_name,
@@ -8473,29 +8647,36 @@ class OTCS:
8473
8647
  )
8474
8648
  if not response:
8475
8649
  self.logger.error(
8476
- "Failed to unpack the transport package -> '%s'",
8650
+ "Failed to unpack the transport package -> '%s'!",
8477
8651
  package_name,
8478
8652
  )
8479
8653
  return None
8480
8654
  self.logger.debug(
8481
- "Successfully unpackaged to workbench -> '%s' (%s)",
8655
+ "Successfully unpackaged to workbench -> '%s' (%s).",
8482
8656
  workbench_name,
8483
8657
  str(workbench_id),
8484
8658
  )
8485
8659
 
8486
8660
  # Step 4: Deploy Workbench
8487
8661
  self.logger.debug(
8488
- "Deploy workbench -> '%s' (%s)",
8662
+ "Deploy workbench -> '%s' (%s)...",
8489
8663
  workbench_name,
8490
8664
  str(workbench_id),
8491
8665
  )
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))
8666
+ response, errors = self.deploy_workbench(workbench_id=workbench_id)
8667
+ if not response or errors > 0:
8668
+ self.logger.error(
8669
+ "Failed to deploy workbench -> '%s' (%s)!%s",
8670
+ workbench_name,
8671
+ str(workbench_id),
8672
+ " {} error{} occured during deployment.".format(errors, "s" if errors > 1 else "")
8673
+ if errors > 0
8674
+ else "",
8675
+ )
8495
8676
  return None
8496
8677
 
8497
8678
  self.logger.debug(
8498
- "Successfully deployed workbench -> '%s' (%s)",
8679
+ "Successfully deployed workbench -> '%s' (%s).",
8499
8680
  workbench_name,
8500
8681
  str(workbench_id),
8501
8682
  )
@@ -8589,7 +8770,7 @@ class OTCS:
8589
8770
  )
8590
8771
  continue
8591
8772
  self.logger.debug(
8592
- "Replace -> %s with -> %s in Transport package -> %s",
8773
+ "Replace -> %s with -> %s in transport package -> %s",
8593
8774
  replacement["placeholder"],
8594
8775
  replacement["value"],
8595
8776
  zip_file_folder,
@@ -8606,21 +8787,21 @@ class OTCS:
8606
8787
  )
8607
8788
  if found:
8608
8789
  self.logger.debug(
8609
- "Replacement -> %s has been completed successfully for Transport package -> %s",
8790
+ "Replacement -> %s has been completed successfully for transport package -> '%s'.",
8610
8791
  replacement,
8611
8792
  zip_file_folder,
8612
8793
  )
8613
8794
  modified = True
8614
8795
  else:
8615
8796
  self.logger.warning(
8616
- "Replacement -> %s not found in Transport package -> %s",
8797
+ "Replacement -> %s not found in transport package -> '%s'!",
8617
8798
  replacement,
8618
8799
  zip_file_folder,
8619
8800
  )
8620
8801
 
8621
8802
  if not modified:
8622
8803
  self.logger.warning(
8623
- "None of the specified replacements have been found in Transport package -> %s. No need to create a new transport package.",
8804
+ "None of the specified replacements have been found in transport package -> %s. No need to create a new transport package.",
8624
8805
  zip_file_folder,
8625
8806
  )
8626
8807
  return False
@@ -8628,7 +8809,7 @@ class OTCS:
8628
8809
  # Create the new zip file and add all files from the directory to it
8629
8810
  new_zip_file_path = os.path.dirname(zip_file_path) + "/new_" + os.path.basename(zip_file_path)
8630
8811
  self.logger.debug(
8631
- "Content of transport -> '%s' has been modified - repacking to new zip file -> %s",
8812
+ "Content of transport -> '%s' has been modified - repacking to new zip file -> '%s'...",
8632
8813
  zip_file_folder,
8633
8814
  new_zip_file_path,
8634
8815
  )
@@ -8645,13 +8826,13 @@ class OTCS:
8645
8826
  zip_ref.close()
8646
8827
  old_zip_file_path = os.path.dirname(zip_file_path) + "/old_" + os.path.basename(zip_file_path)
8647
8828
  self.logger.debug(
8648
- "Rename orginal transport zip file -> '%s' to -> '%s'",
8829
+ "Rename orginal transport zip file -> '%s' to -> '%s'...",
8649
8830
  zip_file_path,
8650
8831
  old_zip_file_path,
8651
8832
  )
8652
8833
  os.rename(zip_file_path, old_zip_file_path)
8653
8834
  self.logger.debug(
8654
- "Rename new transport zip file -> '%s' to -> '%s'",
8835
+ "Rename new transport zip file -> '%s' to -> '%s'...",
8655
8836
  new_zip_file_path,
8656
8837
  zip_file_path,
8657
8838
  )
@@ -8684,7 +8865,7 @@ class OTCS:
8684
8865
  """
8685
8866
 
8686
8867
  if not os.path.isfile(zip_file_path):
8687
- self.logger.error("Zip file -> '%s' not found.", zip_file_path)
8868
+ self.logger.error("Zip file -> '%s' not found!", zip_file_path)
8688
8869
  return False
8689
8870
 
8690
8871
  # Extract the zip file to a temporary directory
@@ -8696,7 +8877,7 @@ class OTCS:
8696
8877
  for extraction in extractions:
8697
8878
  if "xpath" not in extraction:
8698
8879
  self.logger.error(
8699
- "Extraction needs an XPath but it is not specified. Skipping...",
8880
+ "Extraction needs an xpath but it is not specified! Skipping...",
8700
8881
  )
8701
8882
  continue
8702
8883
  # Check if the extraction is explicitly disabled:
@@ -8709,7 +8890,7 @@ class OTCS:
8709
8890
 
8710
8891
  xpath = extraction["xpath"]
8711
8892
  self.logger.debug(
8712
- "Using xpath -> %s to extract the data",
8893
+ "Using xpath -> %s to extract the data.",
8713
8894
  xpath,
8714
8895
  )
8715
8896
 
@@ -8721,7 +8902,7 @@ class OTCS:
8721
8902
  )
8722
8903
  if extracted_data:
8723
8904
  self.logger.debug(
8724
- "Extraction with XPath -> %s has been successfully completed for Transport package -> %s",
8905
+ "Extraction with xpath -> %s has been successfully completed for transport package -> '%s'.",
8725
8906
  xpath,
8726
8907
  zip_file_folder,
8727
8908
  )
@@ -8729,7 +8910,7 @@ class OTCS:
8729
8910
  extraction["data"] = extracted_data
8730
8911
  else:
8731
8912
  self.logger.warning(
8732
- "Extraction with XPath -> %s has not delivered any data for Transport package -> %s",
8913
+ "Extraction with xpath -> %s has not delivered any data for transport package -> '%s'!",
8733
8914
  xpath,
8734
8915
  zip_file_folder,
8735
8916
  )
@@ -8752,6 +8933,36 @@ class OTCS:
8752
8933
  Business Object Types information (for all external systems)
8753
8934
  or None if the request fails.
8754
8935
 
8936
+ Example:
8937
+ {
8938
+ 'links': {
8939
+ 'data': {
8940
+ 'self': {
8941
+ 'body': '',
8942
+ 'content_type': '',
8943
+ 'href': '/api/v2/businessobjecttypes',
8944
+ 'method': 'GET',
8945
+ 'name': ''
8946
+ }
8947
+ }
8948
+ },
8949
+ 'results': [
8950
+ {
8951
+ 'data': {
8952
+ 'properties': {
8953
+ 'bo_type': 'account',
8954
+ 'bo_type_id': 54,
8955
+ 'bo_type_name': 'gw.account',
8956
+ 'ext_system_id': 'Guidewire Policy Center',
8957
+ 'is_default_Search': True,
8958
+ 'workspace_type_id': 33
8959
+ }
8960
+ }
8961
+ },
8962
+ ...
8963
+ ]
8964
+ }
8965
+
8755
8966
  """
8756
8967
 
8757
8968
  request_url = self.config()["businessObjectTypesUrl"]
@@ -9381,6 +9592,7 @@ class OTCS:
9381
9592
 
9382
9593
  # end method definition
9383
9594
 
9595
+ @cache
9384
9596
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_type")
9385
9597
  def get_workspace_type(
9386
9598
  self,
@@ -9401,8 +9613,11 @@ class OTCS:
9401
9613
  Workspace Types or None if the request fails.
9402
9614
 
9403
9615
  Example:
9404
- ```json
9405
- ```
9616
+ {
9617
+ 'icon_url': '/cssupport/otsapxecm/wksp_contract_cust.png',
9618
+ 'is_policies_enabled': False,
9619
+ 'workspace_type': 'Sales Contract'
9620
+ }
9406
9621
 
9407
9622
  """
9408
9623
 
@@ -9580,7 +9795,7 @@ class OTCS:
9580
9795
  def get_workspace(
9581
9796
  self,
9582
9797
  node_id: int,
9583
- fields: (str | list) = "properties", # per default we just get the most important information
9798
+ fields: str | list = "properties", # per default we just get the most important information
9584
9799
  metadata: bool = False,
9585
9800
  ) -> dict | None:
9586
9801
  """Get a workspace based on the node ID.
@@ -9594,11 +9809,11 @@ class OTCS:
9594
9809
  Possible fields include:
9595
9810
  - "properties" (can be further restricted by specifying sub-fields,
9596
9811
  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)
9812
+ - "business_properties" (all the information for the business object and the external system)
9813
+ - "categories" (the category data of the workspace item)
9814
+ - "workspace_references" (a list with the references to business objects in external systems)
9815
+ - "display_urls" (a list with the URLs to external business systems)
9816
+ - "wksp_info" (currently just the icon information of the workspace)
9602
9817
  This parameter can be a string to select one field group or a list of
9603
9818
  strings to select multiple field groups.
9604
9819
  Defaults to "properties".
@@ -9692,10 +9907,31 @@ class OTCS:
9692
9907
  'wksp_type_name': 'Vendor',
9693
9908
  'xgov_workspace_type': ''
9694
9909
  }
9910
+ 'display_urls': [
9911
+ {
9912
+ 'business_object_type': 'LFA1',
9913
+ 'business_object_type_id': 30,
9914
+ 'business_object_type_name': 'Customer',
9915
+ 'displayUrl': '/sap/bc/gui/sap/its/webgui?~logingroup=SPACE&~transaction=%2fOTX%2fRM_WSC_START_BO+KEY%3dpc%3AS3XljqcFfD0pakDIjUKul%3bOBJTYPE%3daccount&~OkCode=ONLI',
9916
+ 'external_system_id': 'TE1',
9917
+ 'external_system_name': 'SAP S/4HANA'
9918
+ }
9919
+ ]
9695
9920
  'wksp_info':
9696
9921
  {
9697
9922
  'wksp_type_icon': '/appimg/ot_bws/icons/16634%2Esvg?v=161194_13949'
9698
9923
  }
9924
+ 'workspace_references': [
9925
+ {
9926
+ 'business_object_id': '0000010020',
9927
+ 'business_object_type': 'LFA1',
9928
+ 'business_object_type_id': 30,
9929
+ 'external_system_id': 'TE1',
9930
+ 'has_default_display': True,
9931
+ 'has_default_search': True,
9932
+ 'workspace_type_id': 37
9933
+ }
9934
+ ]
9699
9935
  },
9700
9936
  'metadata': {...},
9701
9937
  'metadata_order': {
@@ -9703,20 +9939,6 @@ class OTCS:
9703
9939
  }
9704
9940
  }
9705
9941
  ],
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
9942
  }
9721
9943
  ```
9722
9944
 
@@ -9762,6 +9984,8 @@ class OTCS:
9762
9984
  expanded_view: bool = True,
9763
9985
  page: int | None = None,
9764
9986
  limit: int | None = None,
9987
+ fields: str | list = "properties", # per default we just get the most important information
9988
+ metadata: bool = False,
9765
9989
  ) -> dict | None:
9766
9990
  """Get all workspace instances of a given type.
9767
9991
 
@@ -9794,6 +10018,25 @@ class OTCS:
9794
10018
  The page to be returned (if more workspace instances exist
9795
10019
  than given by the page limit).
9796
10020
  The default is None.
10021
+ fields (str | list, optional):
10022
+ Which fields to retrieve. This can have a significant
10023
+ impact on performance.
10024
+ Possible fields include:
10025
+ - "properties" (can be further restricted by specifying sub-fields,
10026
+ e.g., "properties{id,name,parent_id,description}")
10027
+ - "categories"
10028
+ - "versions" (can be further restricted by specifying ".element(0)" to
10029
+ retrieve only the latest version)
10030
+ - "permissions" (can be further restricted by specifying ".limit(5)" to
10031
+ retrieve only the first 5 permissions)
10032
+ This parameter can be a string to select one field group or a list of
10033
+ strings to select multiple field groups.
10034
+ Defaults to "properties".
10035
+ metadata (bool, optional):
10036
+ Whether to return metadata (data type, field length, min/max values,...)
10037
+ about the data.
10038
+ Metadata will be returned under `results.metadata`, `metadata_map`,
10039
+ or `metadata_order`.
9797
10040
 
9798
10041
  Returns:
9799
10042
  dict | None:
@@ -9809,6 +10052,8 @@ class OTCS:
9809
10052
  expanded_view=expanded_view,
9810
10053
  page=page,
9811
10054
  limit=limit,
10055
+ fields=fields,
10056
+ metadata=metadata,
9812
10057
  )
9813
10058
 
9814
10059
  # end method definition
@@ -9819,6 +10064,9 @@ class OTCS:
9819
10064
  type_id: int | None = None,
9820
10065
  expanded_view: bool = True,
9821
10066
  page_size: int = 100,
10067
+ limit: int | None = None,
10068
+ fields: str | list = "properties", # per default we just get the most important information
10069
+ metadata: bool = False,
9822
10070
  ) -> iter:
9823
10071
  """Get an iterator object to traverse all workspace instances of a workspace type.
9824
10072
 
@@ -9849,6 +10097,29 @@ class OTCS:
9849
10097
  page_size (int | None, optional):
9850
10098
  The maximum number of workspace instances that should be delivered in one page.
9851
10099
  The default is 100. If None is given then the internal OTCS limit seems to be 500.
10100
+ limit (int | None = None), optional):
10101
+ The maximum number of workspaces to return in total.
10102
+ If None (default) all workspaces are returned.
10103
+ If a number is provided only up to this number of results is returned.
10104
+ fields (str | list, optional):
10105
+ Which fields to retrieve. This can have a significant
10106
+ impact on performance.
10107
+ Possible fields include:
10108
+ - "properties" (can be further restricted by specifying sub-fields,
10109
+ e.g., "properties{id,name,parent_id,description}")
10110
+ - "categories"
10111
+ - "versions" (can be further restricted by specifying ".element(0)" to
10112
+ retrieve only the latest version)
10113
+ - "permissions" (can be further restricted by specifying ".limit(5)" to
10114
+ retrieve only the first 5 permissions)
10115
+ This parameter can be a string to select one field group or a list of
10116
+ strings to select multiple field groups.
10117
+ Defaults to "properties".
10118
+ metadata (bool, optional):
10119
+ Whether to return metadata (data type, field length, min/max values,...)
10120
+ about the data.
10121
+ Metadata will be returned under `results.metadata`, `metadata_map`,
10122
+ or `metadata_order`.
9852
10123
 
9853
10124
  Returns:
9854
10125
  iter:
@@ -9863,8 +10134,10 @@ class OTCS:
9863
10134
  type_id=type_id,
9864
10135
  name="",
9865
10136
  expanded_view=expanded_view,
9866
- page=1,
9867
10137
  limit=1,
10138
+ page=1,
10139
+ fields=fields,
10140
+ metadata=metadata,
9868
10141
  )
9869
10142
  if not response or "results" not in response:
9870
10143
  # Don't return None! Plain return is what we need for iterators.
@@ -9873,9 +10146,12 @@ class OTCS:
9873
10146
  return
9874
10147
 
9875
10148
  number_of_instances = response["paging"]["total_count"]
10149
+ if limit and number_of_instances > limit:
10150
+ number_of_instances = limit
10151
+
9876
10152
  if not number_of_instances:
9877
10153
  self.logger.debug(
9878
- "Workspace type -> %s does not have instances! Cannot iterate over instances.",
10154
+ "Workspace type -> '%s' does not have instances! Cannot iterate over instances.",
9879
10155
  type_name if type_name else str(type_id),
9880
10156
  )
9881
10157
  # Don't return None! Plain return is what we need for iterators.
@@ -9897,7 +10173,9 @@ class OTCS:
9897
10173
  name="",
9898
10174
  expanded_view=expanded_view,
9899
10175
  page=page,
9900
- limit=page_size,
10176
+ limit=page_size if limit is None else limit,
10177
+ fields=fields,
10178
+ metadata=metadata,
9901
10179
  )
9902
10180
  if not response or not response.get("results", None):
9903
10181
  self.logger.warning(
@@ -9923,9 +10201,9 @@ class OTCS:
9923
10201
  expanded_view: bool = True,
9924
10202
  limit: int | None = None,
9925
10203
  page: int | None = None,
9926
- fields: (str | list) = "properties", # per default we just get the most important information
10204
+ fields: str | list = "properties", # per default we just get the most important information
9927
10205
  metadata: bool = False,
9928
- timeout: int = REQUEST_TIMEOUT,
10206
+ timeout: float = REQUEST_TIMEOUT,
9929
10207
  ) -> dict | None:
9930
10208
  """Lookup workspaces based on workspace type and workspace name.
9931
10209
 
@@ -9981,7 +10259,7 @@ class OTCS:
9981
10259
  about the data.
9982
10260
  Metadata will be returned under `results.metadata`, `metadata_map`,
9983
10261
  or `metadata_order`.
9984
- timeout (int, optional):
10262
+ timeout (float, optional):
9985
10263
  Specific timeout for the request in seconds. The default is the standard
9986
10264
  timeout value REQUEST_TIMEOUT used by the OTCS module.
9987
10265
 
@@ -10191,7 +10469,7 @@ class OTCS:
10191
10469
  external_system_name: str,
10192
10470
  business_object_type: str,
10193
10471
  business_object_id: str,
10194
- return_workspace_metadata: bool = False,
10472
+ metadata: bool = False,
10195
10473
  show_error: bool = False,
10196
10474
  ) -> dict | None:
10197
10475
  """Get a workspace based on the business object of an external system.
@@ -10203,7 +10481,7 @@ class OTCS:
10203
10481
  Type of the Business object, e.g. KNA1 for SAP customers
10204
10482
  business_object_id (str):
10205
10483
  ID of the business object in the external system
10206
- return_workspace_metadata (bool, optional):
10484
+ metadata (bool, optional):
10207
10485
  Whether or not workspace metadata (categories) should be returned.
10208
10486
  Default is False.
10209
10487
  show_error (bool, optional):
@@ -10285,7 +10563,7 @@ class OTCS:
10285
10563
  + "/boids/"
10286
10564
  + business_object_id
10287
10565
  )
10288
- if return_workspace_metadata:
10566
+ if metadata:
10289
10567
  request_url += "?metadata"
10290
10568
 
10291
10569
  request_header = self.request_form_header()
@@ -10319,7 +10597,7 @@ class OTCS:
10319
10597
  # end method definition
10320
10598
 
10321
10599
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="lookup_workspace")
10322
- def lookup_workspace(
10600
+ def lookup_workspaces(
10323
10601
  self,
10324
10602
  type_name: str,
10325
10603
  category: str,
@@ -10327,11 +10605,12 @@ class OTCS:
10327
10605
  value: str,
10328
10606
  attribute_set: str | None = None,
10329
10607
  ) -> dict | None:
10330
- """Lookup the workspace that has a specified value in a category attribute.
10608
+ """Lookup workspaces that have a specified value in a category attribute.
10331
10609
 
10332
10610
  Args:
10333
10611
  type_name (str):
10334
- The name of the workspace type.
10612
+ The name of the workspace type. This is required to determine
10613
+ the parent folder in which the workspaces of this type reside.
10335
10614
  category (str):
10336
10615
  The name of the category.
10337
10616
  attribute (str):
@@ -10339,7 +10618,8 @@ class OTCS:
10339
10618
  value (str):
10340
10619
  The lookup value that is matched agains the node attribute value.
10341
10620
  attribute_set (str | None, optional):
10342
- The name of the attribute set
10621
+ The name of the attribute set. If None (default) the attribute to lookup
10622
+ is supposed to be a top-level attribute.
10343
10623
 
10344
10624
  Returns:
10345
10625
  dict | None:
@@ -10358,12 +10638,44 @@ class OTCS:
10358
10638
  )
10359
10639
  return None
10360
10640
 
10361
- return self.lookup_node(
10641
+ return self.lookup_nodes(
10362
10642
  parent_node_id=parent_id, category=category, attribute=attribute, value=value, attribute_set=attribute_set
10363
10643
  )
10364
10644
 
10365
10645
  # end method definition
10366
10646
 
10647
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace")
10648
+ def get_workspace_references(
10649
+ self,
10650
+ node_id: int,
10651
+ ) -> list | None:
10652
+ """Get a workspace rewferences to business objects in external systems.
10653
+
10654
+ Args:
10655
+ node_id (int):
10656
+ The node ID of the workspace to retrieve.
10657
+
10658
+ Returns:
10659
+ list | None:
10660
+ A List of references to business objects in external systems.
10661
+
10662
+ """
10663
+
10664
+ response = self.get_workspace(node_id=node_id, fields="workspace_references")
10665
+
10666
+ results = response.get("results")
10667
+ if not results:
10668
+ return None
10669
+ data = results.get("data")
10670
+ if not data:
10671
+ return None
10672
+
10673
+ workspace_references: list = data.get("workspace_references")
10674
+
10675
+ return workspace_references
10676
+
10677
+ # end method definition
10678
+
10367
10679
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="set_workspace_reference")
10368
10680
  def set_workspace_reference(
10369
10681
  self,
@@ -10422,13 +10734,88 @@ class OTCS:
10422
10734
  headers=request_header,
10423
10735
  data=workspace_put_data,
10424
10736
  timeout=None,
10425
- warning_message="Cannot update reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10737
+ warning_message="Cannot update reference for workspace ID -> {} with business object connection -> ('{}', '{}', {})".format(
10426
10738
  workspace_id,
10427
10739
  external_system_id,
10428
10740
  bo_type,
10429
10741
  bo_id,
10430
10742
  ),
10431
- failure_message="Failed to update reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10743
+ failure_message="Failed to update reference for workspace ID -> {} with business object connection -> ('{}', '{}', {})".format(
10744
+ workspace_id,
10745
+ external_system_id,
10746
+ bo_type,
10747
+ bo_id,
10748
+ ),
10749
+ show_error=show_error,
10750
+ )
10751
+
10752
+ # end method definition
10753
+
10754
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="delete_workspace_reference")
10755
+ def delete_workspace_reference(
10756
+ self,
10757
+ workspace_id: int,
10758
+ external_system_id: str | None = None,
10759
+ bo_type: str | None = None,
10760
+ bo_id: str | None = None,
10761
+ show_error: bool = True,
10762
+ ) -> dict | None:
10763
+ """Delete reference of workspace to a business object in an external system.
10764
+
10765
+ Args:
10766
+ workspace_id (int):
10767
+ The ID of the workspace.
10768
+ external_system_id (str | None, optional):
10769
+ Identifier of the external system (None if no external system).
10770
+ bo_type (str | None, optional):
10771
+ Business object type (None if no external system)
10772
+ bo_id (str | None, optional):
10773
+ Business object identifier / key (None if no external system)
10774
+ show_error (bool, optional):
10775
+ Log an error if workspace cration fails. Otherwise log a warning.
10776
+
10777
+ Returns:
10778
+ Request response or None in case of an error.
10779
+
10780
+ """
10781
+
10782
+ request_url = self.config()["businessWorkspacesUrl"] + "/" + str(workspace_id) + "/workspacereferences"
10783
+ request_header = self.request_form_header()
10784
+
10785
+ if not external_system_id or not bo_type or not bo_id:
10786
+ self.logger.error(
10787
+ "Cannot update workspace reference - required Business Object information is missing!",
10788
+ )
10789
+ return None
10790
+
10791
+ self.logger.debug(
10792
+ "Delete workspace reference of workspace ID -> %d with business object connection -> (%s, %s, %s); calling -> %s",
10793
+ workspace_id,
10794
+ external_system_id,
10795
+ bo_type,
10796
+ bo_id,
10797
+ request_url,
10798
+ )
10799
+
10800
+ workspace_put_data = {
10801
+ "ext_system_id": external_system_id,
10802
+ "bo_type": bo_type,
10803
+ "bo_id": bo_id,
10804
+ }
10805
+
10806
+ return self.do_request(
10807
+ url=request_url,
10808
+ method="DELETE",
10809
+ headers=request_header,
10810
+ data=workspace_put_data,
10811
+ timeout=None,
10812
+ warning_message="Cannot delete reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10813
+ workspace_id,
10814
+ external_system_id,
10815
+ bo_type,
10816
+ bo_id,
10817
+ ),
10818
+ failure_message="Failed to delete reference for workspace ID -> {} with business object connection -> ({}, {}, {})".format(
10432
10819
  workspace_id,
10433
10820
  external_system_id,
10434
10821
  bo_type,
@@ -10475,26 +10862,26 @@ class OTCS:
10475
10862
  A description of the new workspace.
10476
10863
  workspace_type (int):
10477
10864
  Type ID of the workspace, indicating its category or function.
10478
- category_data (dict, optional):
10865
+ category_data (dict | None, optional):
10479
10866
  Category and attribute data for the workspace.
10480
- classifications (list):
10867
+ classifications (list | None, optional):
10481
10868
  List of classification item IDs to apply to the new item.
10482
- external_system_id (str, optional):
10869
+ external_system_id (str | None, optional):
10483
10870
  External system identifier if linking the workspace to an external system.
10484
- bo_type (str, optional):
10871
+ bo_type (str | None, optional):
10485
10872
  Business object type, used if linking to an external system.
10486
- bo_id (str, optional):
10873
+ bo_id (str | None, optional):
10487
10874
  Business object identifier or key, used if linking to an external system.
10488
- parent_id (int, optional):
10875
+ parent_id (int | None, optional):
10489
10876
  ID of the parent workspace, required in special cases such as
10490
10877
  sub-workspaces or location ambiguity.
10491
- ibo_workspace_id (int, optional):
10878
+ ibo_workspace_id (int | None, optional):
10492
10879
  ID of an existing workspace that is already linked to an external system.
10493
10880
  Allows connecting multiple business objects (IBO).
10494
- external_create_date (str, optional):
10881
+ external_create_date (str | None, optional):
10495
10882
  Date of creation in the external system (format: YYYY-MM-DD).
10496
10883
  None is the default.
10497
- external_modify_date (str, optional):
10884
+ external_modify_date (str | None, optional):
10498
10885
  Date of last modification in the external system (format: YYYY-MM-DD).
10499
10886
  None is the default.
10500
10887
  show_error (bool, optional):
@@ -10670,16 +11057,16 @@ class OTCS:
10670
11057
  category_data (dict | None, optional):
10671
11058
  Category and attribute data.
10672
11059
  Default is None (attributes remain unchanged).
10673
- external_system_id (str, optional):
11060
+ external_system_id (str | None, optional):
10674
11061
  Identifier of the external system (None if no external system)
10675
- bo_type (str, optional):
11062
+ bo_type (str | None, optional):
10676
11063
  Business object type (None if no external system)
10677
- bo_id (str, optional):
11064
+ bo_id (str | None, optional):
10678
11065
  Business object identifier / key (None if no external system)
10679
- external_create_date (str, optional):
11066
+ external_create_date (str | None, optional):
10680
11067
  Date of creation in the external system
10681
11068
  (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
10682
- external_modify_date (str, optional):
11069
+ external_modify_date (str | None, optional):
10683
11070
  Date of last modification in the external system
10684
11071
  (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
10685
11072
  show_error (bool, optional):
@@ -10787,12 +11174,13 @@ class OTCS:
10787
11174
  def get_workspace_relationships(
10788
11175
  self,
10789
11176
  workspace_id: int,
10790
- relationship_type: str | list | None = None,
11177
+ relationship_type: str | list = "child",
10791
11178
  related_workspace_name: str | None = None,
10792
11179
  related_workspace_type_id: int | list | None = None,
10793
11180
  limit: int | None = None,
10794
11181
  page: int | None = None,
10795
- fields: (str | list) = "properties", # per default we just get the most important information
11182
+ fields: str | list = "properties", # per default we just get the most important information
11183
+ metadata: bool = False,
10796
11184
  ) -> dict | None:
10797
11185
  """Get the Workspace relationships to other workspaces.
10798
11186
 
@@ -10804,7 +11192,7 @@ class OTCS:
10804
11192
  workspace_id (int):
10805
11193
  The ID of the workspace.
10806
11194
  relationship_type (str | list, optional):
10807
- Either "parent" or "child" (or None = unspecified which is the default).
11195
+ Either "parent" or "child" ("child" is the default).
10808
11196
  If both ("child" and "parent") are requested then use a
10809
11197
  list like ["child", "parent"].
10810
11198
  related_workspace_name (str | None, optional):
@@ -10834,6 +11222,9 @@ class OTCS:
10834
11222
  This parameter can be a string to select one field group or a list of
10835
11223
  strings to select multiple field groups.
10836
11224
  Defaults to "properties".
11225
+ metadata (bool, optional):
11226
+ Whether or not workspace metadata (categories) should be returned.
11227
+ Default is False.
10837
11228
 
10838
11229
  Returns:
10839
11230
  dict | None:
@@ -10928,7 +11319,13 @@ class OTCS:
10928
11319
  if isinstance(relationship_type, str):
10929
11320
  query["where_relationtype"] = relationship_type
10930
11321
  elif isinstance(relationship_type, list):
10931
- query["where_rel_types"] = relationship_type
11322
+ if any(rt not in ["parent", "child"] for rt in relationship_type):
11323
+ self.logger.error(
11324
+ "Illegal relationship type for related workspace type! Must be either 'parent' or 'child'. -> %s",
11325
+ relationship_type,
11326
+ )
11327
+ return None
11328
+ query["where_rel_types"] = "{'" + ("','").join(relationship_type) + "'}"
10932
11329
  else:
10933
11330
  self.logger.error(
10934
11331
  "Illegal relationship type for related workspace type!",
@@ -10956,6 +11353,8 @@ class OTCS:
10956
11353
 
10957
11354
  encoded_query = urllib.parse.urlencode(query=query, doseq=False)
10958
11355
  request_url += "?{}".format(encoded_query)
11356
+ if metadata:
11357
+ request_url += "&metadata"
10959
11358
 
10960
11359
  request_header = self.request_form_header()
10961
11360
 
@@ -10980,11 +11379,13 @@ class OTCS:
10980
11379
  def get_workspace_relationships_iterator(
10981
11380
  self,
10982
11381
  workspace_id: int,
10983
- relationship_type: str | list | None = None,
11382
+ relationship_type: str | list = "child",
10984
11383
  related_workspace_name: str | None = None,
10985
11384
  related_workspace_type_id: int | list | None = None,
10986
- fields: (str | list) = "properties", # per default we just get the most important information
11385
+ fields: str | list = "properties", # per default we just get the most important information
10987
11386
  page_size: int = 100,
11387
+ limit: int | None = None,
11388
+ metadata: bool = False,
10988
11389
  ) -> iter:
10989
11390
  """Get an iterator object to traverse all related workspaces for a workspace.
10990
11391
 
@@ -11005,7 +11406,7 @@ class OTCS:
11005
11406
  workspace_id (int):
11006
11407
  The ID of the workspace.
11007
11408
  relationship_type (str | list, optional):
11008
- Either "parent" or "child" (or None = unspecified which is the default).
11409
+ Either "parent" or "child" ("child" is the default).
11009
11410
  If both ("child" and "parent") are requested then use a
11010
11411
  list like ["child", "parent"].
11011
11412
  related_workspace_name (str | None, optional):
@@ -11015,14 +11416,12 @@ class OTCS:
11015
11416
  fields (str | list, optional):
11016
11417
  Which fields to retrieve. This can have a significant
11017
11418
  impact on performance.
11018
- Possible fields include:
11419
+ Possible fields include (NOTE: "categories" is not supported in this method!!):
11019
11420
  - "properties" (can be further restricted by specifying sub-fields,
11020
11421
  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)
11422
+ - "business_properties" (all the information for the business object and the external system)
11423
+ - "workspace_references" (a list with the references to business objects in external systems)
11424
+ - "wksp_info" (currently just the icon information of the workspace)
11026
11425
  This parameter can be a string to select one field group or a list of
11027
11426
  strings to select multiple field groups.
11028
11427
  Defaults to "properties".
@@ -11032,6 +11431,14 @@ class OTCS:
11032
11431
  The default is None, in this case the internal OTCS limit seems
11033
11432
  to be 500.
11034
11433
  This is basically the chunk size for the iterator.
11434
+ limit (int | None = None), optional):
11435
+ The maximum number of workspaces to return in total.
11436
+ If None (default) all workspaces are returned.
11437
+ If a number is provided only up to this number of results is returned.
11438
+ metadata (bool, optional):
11439
+ Whether or not workspace metadata should be returned. These are
11440
+ the system level metadata - not the categories of the workspace!
11441
+ Default is False.
11035
11442
 
11036
11443
  Returns:
11037
11444
  iter:
@@ -11049,6 +11456,7 @@ class OTCS:
11049
11456
  limit=1,
11050
11457
  page=1,
11051
11458
  fields=fields,
11459
+ metadata=metadata,
11052
11460
  )
11053
11461
  if not response or "results" not in response:
11054
11462
  # Don't return None! Plain return is what we need for iterators.
@@ -11057,6 +11465,9 @@ class OTCS:
11057
11465
  return
11058
11466
 
11059
11467
  number_of_related_workspaces = response["paging"]["total_count"]
11468
+ if limit and number_of_related_workspaces > limit:
11469
+ number_of_related_workspaces = limit
11470
+
11060
11471
  if not number_of_related_workspaces:
11061
11472
  self.logger.debug(
11062
11473
  "Workspace with node ID -> %d does not have related workspaces! Cannot iterate over related workspaces.",
@@ -11080,9 +11491,10 @@ class OTCS:
11080
11491
  relationship_type=relationship_type,
11081
11492
  related_workspace_name=related_workspace_name,
11082
11493
  related_workspace_type_id=related_workspace_type_id,
11083
- limit=page_size,
11494
+ limit=page_size if limit is None else limit,
11084
11495
  page=page,
11085
11496
  fields=fields,
11497
+ metadata=metadata,
11086
11498
  )
11087
11499
  if not response or not response.get("results", None):
11088
11500
  self.logger.warning(
@@ -11314,6 +11726,46 @@ class OTCS:
11314
11726
  dict | None:
11315
11727
  Workspace Role Membership or None if the request fails.
11316
11728
 
11729
+ Example:
11730
+ {
11731
+ 'links': {
11732
+ 'data': {
11733
+ 'self': {
11734
+ 'body': '',
11735
+ 'content_type': '',
11736
+ 'href': '/api/v2/businessworkspaces/80998/roles/81001/members',
11737
+ 'method': 'POST',
11738
+ 'name': ''
11739
+ }
11740
+ },
11741
+ 'results': {
11742
+ 'data': {
11743
+ 'properties': {
11744
+ 'birth_date': None,
11745
+ 'business_email': 'lwhite@terrarium.cloud',
11746
+ 'business_fax': None,
11747
+ 'business_phone': '+1 (345) 4626-333',
11748
+ 'cell_phone': None,
11749
+ 'deleted': False,
11750
+ 'display_language': None,
11751
+ 'display_name': 'Liz White',
11752
+ 'first_name': 'Liz',
11753
+ 'gender': None,
11754
+ 'group_id': 16178,
11755
+ 'group_name': 'Executive Leadership Team',
11756
+ 'home_address_1': None,
11757
+ 'home_address_2': None,
11758
+ 'home_fax': None,
11759
+ 'home_phone': None,
11760
+ 'id': 15520,
11761
+ 'initials': 'LW',
11762
+ 'last_name': 'White',
11763
+ ...
11764
+ }
11765
+ }
11766
+ }
11767
+ }
11768
+
11317
11769
  """
11318
11770
 
11319
11771
  self.logger.debug(
@@ -12022,7 +12474,7 @@ class OTCS:
12022
12474
 
12023
12475
  Args:
12024
12476
  parent_id (int):
12025
- The node the category should be applied to.
12477
+ The parent node of the new node to create.
12026
12478
  subtype (int, optional):
12027
12479
  The subtype of the new node. Default is document.
12028
12480
  category_ids (int | list[int], optional):
@@ -12030,7 +12482,7 @@ class OTCS:
12030
12482
 
12031
12483
  Returns:
12032
12484
  dict | None:
12033
- Workspace Create Form data or None if the request fails.
12485
+ Node create form data or None if the request fails.
12034
12486
 
12035
12487
  """
12036
12488
 
@@ -12065,6 +12517,151 @@ class OTCS:
12065
12517
 
12066
12518
  # end method definition
12067
12519
 
12520
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_node_category_update_form")
12521
+ def get_node_category_form(
12522
+ self,
12523
+ node_id: int,
12524
+ category_id: int | None = None,
12525
+ operation: str = "update",
12526
+ ) -> dict | None:
12527
+ """Get the node category update form.
12528
+
12529
+ Args:
12530
+ node_id (int):
12531
+ The ID of the node to update.
12532
+ category_id (int | None, optional):
12533
+ The ID of the category to update.
12534
+ operation (str, optional):
12535
+ The operation to perform. Default is "update". Other possible value is "create".
12536
+
12537
+ Returns:
12538
+ dict | None:
12539
+ Workspace Category Update Form data or None if the request fails.
12540
+
12541
+ Example:
12542
+ {
12543
+ 'forms': [
12544
+ {
12545
+ 'data': {
12546
+ '20581_1': {'metadata_token': ''},
12547
+ '20581_10': None,
12548
+ '20581_11': None,
12549
+ '20581_12': None,
12550
+ '20581_13': None,
12551
+ '20581_14': [
12552
+ {
12553
+ '20581_14_x_15': None,
12554
+ '20581_14_x_16': None,
12555
+ '20581_14_x_17': None,
12556
+ '20581_14_x_18': None,
12557
+ '20581_14_x_19': None,
12558
+ '20581_14_x_20': None,
12559
+ '20581_14_x_21': None,
12560
+ '20581_14_x_22': None
12561
+ }
12562
+ ],
12563
+ '20581_14_1': None,
12564
+ '20581_2': None,
12565
+ '20581_23': [
12566
+ {
12567
+ '20581_23_x_25': None,
12568
+ '20581_23_x_26': None,
12569
+ '20581_23_x_27': None,
12570
+ '20581_23_x_28': None,
12571
+ '20581_23_x_29': None,
12572
+ '20581_23_x_30': None,
12573
+ '20581_23_x_31': None,
12574
+ '20581_23_x_32': None,
12575
+ '20581_23_x_37': None
12576
+ }
12577
+ ],
12578
+ '20581_23_1': None,
12579
+ '20581_3': None,
12580
+ '20581_33': {
12581
+ '20581_33_1_34': None,
12582
+ '20581_33_1_35': None,
12583
+ '20581_33_1_36': None
12584
+ },
12585
+ '20581_4': None,
12586
+ '20581_5': None,
12587
+ '20581_6': None,
12588
+ '20581_7': None,
12589
+ '20581_8': None,
12590
+ '20581_9': None
12591
+ },
12592
+ 'options': {
12593
+ 'fields': {...},
12594
+ 'form': {...}
12595
+ },
12596
+ 'schema': {
12597
+ 'properties': {
12598
+ '20581_1': {
12599
+ 'readonly': True,
12600
+ 'required': False, 'type': 'object'},
12601
+ '20581_2': {
12602
+ 'maxLength': 20,
12603
+ 'multilingual': None,
12604
+ 'readonly': False,
12605
+ 'required': False,
12606
+ 'title': 'Order Number',
12607
+ 'type': 'string'
12608
+ },
12609
+ '20581_11': {
12610
+ 'maxLength': 25,
12611
+ 'multilingual': None,
12612
+ 'readonly': False,
12613
+ 'required': False,
12614
+ 'title': 'Order Type',
12615
+ 'type': 'string'
12616
+ },
12617
+ ... (more fields) ...
12618
+ },
12619
+ 'type': 'object'
12620
+ }
12621
+ }
12622
+ ]
12623
+ }
12624
+
12625
+ """
12626
+
12627
+ request_header = self.request_form_header()
12628
+
12629
+ # If no category ID is provided get the current category IDs of the node and take the first one.
12630
+ # TODO: we need to be more clever here if multiple categories are assigned to a node.
12631
+ if category_id is None:
12632
+ category_ids = self.get_node_category_ids(node_id=node_id)
12633
+ if not category_ids or not isinstance(category_ids, list):
12634
+ self.logger.error("Cannot get category IDs for node with ID -> %s", str(node_id))
12635
+ return None
12636
+ category_id = category_ids[0]
12637
+
12638
+ self.logger.debug(
12639
+ "Get category %s form for node ID -> %s and category ID -> %s",
12640
+ operation,
12641
+ str(node_id),
12642
+ str(category_id),
12643
+ )
12644
+
12645
+ request_url = self.config()["nodesFormUrl"] + "/categories/{}?id={}&category_id={}".format(
12646
+ operation, node_id, category_id
12647
+ )
12648
+
12649
+ response = self.do_request(
12650
+ url=request_url,
12651
+ method="GET",
12652
+ headers=request_header,
12653
+ timeout=None,
12654
+ failure_message="Cannot get category {} form for node ID -> {} and category ID -> {}".format(
12655
+ operation,
12656
+ node_id,
12657
+ category_id,
12658
+ ),
12659
+ )
12660
+
12661
+ return response
12662
+
12663
+ # end method definition
12664
+
12068
12665
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="set_system_attributes")
12069
12666
  def set_system_attributes(
12070
12667
  self,
@@ -12465,7 +13062,7 @@ class OTCS:
12465
13062
  request_header = self.request_form_header()
12466
13063
 
12467
13064
  self.logger.debug(
12468
- "Running Web Report with nickname -> '%s'; calling -> %s",
13065
+ "Running web report with nickname -> '%s'; calling -> %s",
12469
13066
  nickname,
12470
13067
  request_url,
12471
13068
  )
@@ -12503,7 +13100,7 @@ class OTCS:
12503
13100
  request_header = self.request_form_header()
12504
13101
 
12505
13102
  self.logger.debug(
12506
- "Install CS Application -> '%s'; calling -> %s",
13103
+ "Install OTCS application -> '%s'; calling -> %s",
12507
13104
  application_name,
12508
13105
  request_url,
12509
13106
  )
@@ -12514,7 +13111,7 @@ class OTCS:
12514
13111
  headers=request_header,
12515
13112
  data=install_cs_application_post_data,
12516
13113
  timeout=None,
12517
- failure_message="Failed to install CS Application -> '{}'".format(
13114
+ failure_message="Failed to install OTCS application -> '{}'".format(
12518
13115
  application_name,
12519
13116
  ),
12520
13117
  )
@@ -12783,6 +13380,64 @@ class OTCS:
12783
13380
 
12784
13381
  # end method definition
12785
13382
 
13383
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="check_user_node_permissions")
13384
+ def check_user_node_permissions(self, node_ids: list[int]) -> dict | None:
13385
+ """Check if the current user has permissions to access a given list of Content Server nodes.
13386
+
13387
+ This is using the AI endpoint has this method is typically used in Aviator use cases.
13388
+
13389
+ Args:
13390
+ node_ids (list[int]):
13391
+ List of node IDs to check.
13392
+
13393
+ Returns:
13394
+ dict | None:
13395
+ REST API response or None in case of an error.
13396
+
13397
+ Example:
13398
+ {
13399
+ 'links': {
13400
+ 'data': {
13401
+ self': {
13402
+ 'body': '',
13403
+ 'content_type': '',
13404
+ 'href': '/api/v2/ai/nodes/permissions/check',
13405
+ 'method': 'POST',
13406
+ 'name': ''
13407
+ }
13408
+ }
13409
+ },
13410
+ 'results': {
13411
+ 'ids': [...]
13412
+ }
13413
+ }
13414
+
13415
+ """
13416
+
13417
+ request_url = self.config()["aiUrl"] + "/permissions/check"
13418
+ request_header = self.request_form_header()
13419
+
13420
+ permission_post_data = {"ids": node_ids}
13421
+
13422
+ if float(self.get_server_version()) < 25.4:
13423
+ permission_post_data["user_hash"] = self.otcs_ticket_hashed()
13424
+
13425
+ self.logger.debug(
13426
+ "Check if current user has permissions to access nodes -> %s; calling -> %s",
13427
+ node_ids,
13428
+ request_url,
13429
+ )
13430
+
13431
+ return self.do_request(
13432
+ url=request_url,
13433
+ method="POST",
13434
+ headers=request_header,
13435
+ data=permission_post_data,
13436
+ failure_message="Failed to check if current user has permissions to access nodes -> {}".format(node_ids),
13437
+ )
13438
+
13439
+ # end method definition
13440
+
12786
13441
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_node_categories")
12787
13442
  def get_node_categories(self, node_id: int, metadata: bool = True) -> dict | None:
12788
13443
  """Get categories assigned to a node.
@@ -12946,7 +13601,7 @@ class OTCS:
12946
13601
 
12947
13602
  Returns:
12948
13603
  dict | None:
12949
- REST esponse with category data or None if the call to the REST API fails.
13604
+ REST response with category data or None if the call to the REST API fails.
12950
13605
 
12951
13606
  """
12952
13607
 
@@ -13207,9 +13862,129 @@ class OTCS:
13207
13862
  set_name = None
13208
13863
  set_id = None
13209
13864
 
13210
- return cat_id, attribute_definitions
13865
+ return cat_id, attribute_definitions
13866
+
13867
+ return -1, {}
13868
+
13869
+ # end method definition
13870
+
13871
+ def get_category_id_by_name(self, node_id: int, category_name: str) -> int | None:
13872
+ """Get the category ID by its name.
13873
+
13874
+ Args:
13875
+ node_id (int):
13876
+ The ID of the node to get the categories for.
13877
+ category_name (str):
13878
+ The name of the category to get the ID for.
13879
+
13880
+ Returns:
13881
+ int | None:
13882
+ The category ID or None if the category is not found.
13883
+
13884
+ """
13885
+
13886
+ response = self.get_node_categories(node_id=node_id)
13887
+ results = response["results"]
13888
+ for result in results:
13889
+ categories = result["metadata"]["categories"]
13890
+ first_key = next(iter(categories))
13891
+ if categories[first_key]["name"] == category_name:
13892
+ return first_key
13893
+ return None
13894
+
13895
+ # end method definition
13896
+
13897
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_category_as_dictionary")
13898
+ def get_node_category_as_dictionary(
13899
+ self, node_id: int, category_id: int | None = None, category_name: str | None = None
13900
+ ) -> dict | None:
13901
+ """Get a specific category assigned to a node in a streamlined Python dictionary form.
13902
+
13903
+ * The whole category data of a node is embedded into a python dict.
13904
+ * Single-value / scalar attributes are key / value pairs in that dict.
13905
+ * Multi-value attributes become key / value pairs with value being a list of strings or integers.
13906
+ * Single-line sets become key /value pairs with value being a sub-dict.
13907
+ * Attribute in single-line sets become key / value pairs in the sub-dict.
13908
+ * Multi-line sets become key / value pairs with value being a list of dicts.
13909
+ * Single-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list.
13910
+ * Multi-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list
13911
+ with value being a list of strings or integers.
13912
+
13913
+ See also extract_category_data() for an alternative implementation.
13914
+
13915
+ Args:
13916
+ node_id (int):
13917
+ The ID of the node to get the categories for.
13918
+ category_id (int | None, optional):
13919
+ The node ID of the category definition (in category volume). If not provided,
13920
+ the category ID is determined by its name.
13921
+ category_name (str | None, optional):
13922
+ The name of the category to get the ID for.
13923
+ If category_id is not provided, the category ID is determined by its name.
13924
+
13925
+ Returns:
13926
+ dict | None:
13927
+ REST response with category data or None if the call to the REST API fails.
13928
+
13929
+ """
13930
+
13931
+ if not category_id and not category_name:
13932
+ self.logger.error("Either category ID or category name must be provided!")
13933
+ return None
13934
+
13935
+ if not category_id:
13936
+ category_id = self.get_category_id_by_name(node_id=node_id, category_name=category_name)
13937
+
13938
+ response = self.get_node_category(node_id=node_id, category_id=category_id)
13939
+
13940
+ data = response["results"]["data"]["categories"]
13941
+ metadata = response["results"]["metadata"]["categories"]
13942
+ category_key = next(iter(metadata))
13943
+ _ = metadata.pop(category_key)
13944
+
13945
+ # Initialize the result dict:
13946
+ result = {}
13211
13947
 
13212
- return -1, {}
13948
+ for key, attribute in metadata.items():
13949
+ is_set = attribute["persona"] == "set"
13950
+ is_multi_value = attribute["multi_value"]
13951
+ attr_name = attribute["name"]
13952
+ attr_key = attribute["key"]
13953
+
13954
+ if is_set:
13955
+ set_name = attr_name
13956
+ set_multi_value = is_multi_value
13957
+
13958
+ if not is_set and "x" not in attr_key:
13959
+ result[attr_name] = data[key]
13960
+ set_name = None
13961
+ elif is_set:
13962
+ # The current attribute is the set itself:
13963
+ if not is_multi_value:
13964
+ result[attr_name] = {}
13965
+ else:
13966
+ result[attr_name] = []
13967
+ set_name = attr_name
13968
+ elif not is_set and "x" in attr_key:
13969
+ # We re inside a set and process the set attributes:
13970
+ if not set_multi_value:
13971
+ # A single row set:
13972
+ attr_key = attr_key.replace("_x_", "_1_")
13973
+ result[set_name][attr_name] = data[attr_key]
13974
+ else:
13975
+ # Collect all the row data:
13976
+ for index in range(1, 50):
13977
+ attr_key_index = attr_key.replace("_x_", "_" + str(index) + "_")
13978
+ # Do we have data for this row?
13979
+ if attr_key_index in data:
13980
+ if index > len(result[set_name]):
13981
+ result[set_name].append({attr_name: data[attr_key_index]})
13982
+ else:
13983
+ result[set_name][index - 1][attr_name] = data[attr_key_index]
13984
+ else:
13985
+ # No more rows
13986
+ break
13987
+ return result
13213
13988
 
13214
13989
  # end method definition
13215
13990
 
@@ -13735,6 +14510,8 @@ class OTCS:
13735
14510
  * Multi-value attributes in multi-line sets become key / value pairs inside the dict at the row position in the list
13736
14511
  with value being a list of strings or integers.
13737
14512
 
14513
+ See also get_node_category_as_dictionary() for an alternative implementation.
14514
+
13738
14515
  Args:
13739
14516
  node (dict):
13740
14517
  The typical node response of a node get REST API call that include the "categories" fields.
@@ -13806,14 +14583,37 @@ class OTCS:
13806
14583
 
13807
14584
  # Start of main method body:
13808
14585
 
13809
- if not node or "results" not in node:
14586
+ if not node:
14587
+ self.logger.error("Cannot extract category data. No node data provided!")
13810
14588
  return None
13811
14589
 
14590
+ if "results" not in node:
14591
+ # Support also iterators that have resolved the "results" already.
14592
+ # In this case we wrap it in a "rsults" dict to make it look like
14593
+ # a full response:
14594
+ if "data" in node:
14595
+ node = {"results": node}
14596
+ else:
14597
+ return None
14598
+
14599
+ # Some OTCS REST APIs may return a list of nodes in "results".
14600
+ # We only support processing a single node here:
14601
+ if isinstance(node["results"], list):
14602
+ if len(node["results"]) > 1:
14603
+ self.logger.warning("Response includes a node list. Extracting category data for the first node!")
14604
+ node["results"] = node["results"][0]
14605
+
13812
14606
  if "metadata" not in node["results"]:
13813
- self.logger.error("Cannot exteact metadata. Node method was called without the '&metadata' parameter!")
14607
+ self.logger.error("Cannot extract category data. Method was called without the '&metadata' parameter!")
13814
14608
  return None
13815
14609
 
13816
- category_schemas = node["results"]["metadata"]["categories"]
14610
+ metadata = node["results"]["metadata"]
14611
+ if "categories" not in metadata:
14612
+ self.logger.error(
14613
+ "Cannot extract category data. No category data found in node response! Use 'categories' value for 'fields' parameter in the node call!"
14614
+ )
14615
+ return None
14616
+ category_schemas = metadata["categories"]
13817
14617
 
13818
14618
  result_dict = {}
13819
14619
  current_dict = result_dict
@@ -13821,6 +14621,15 @@ class OTCS:
13821
14621
  category_lookup = {}
13822
14622
  attribute_lookup = {}
13823
14623
 
14624
+ # Some REST API return categories in different format. We adjust
14625
+ # it on the fly here:
14626
+ if isinstance(category_schemas, list):
14627
+ new_schema = {}
14628
+ for category_schema in category_schemas:
14629
+ first_key = next(iter(category_schema))
14630
+ new_schema[first_key] = category_schema
14631
+ category_schemas = new_schema
14632
+
13824
14633
  try:
13825
14634
  for category_key, category_schema in category_schemas.items():
13826
14635
  for attribute_key, attribute_schema in category_schema.items():
@@ -13888,9 +14697,17 @@ class OTCS:
13888
14697
  self.logger.error("Type -> '%s' not handled yet!", attribute_type)
13889
14698
  except Exception as e:
13890
14699
  self.logger.error("Something went wrong with getting the data schema! Error -> %s", str(e))
14700
+ return None
13891
14701
 
13892
14702
  category_datas = node["results"]["data"]["categories"]
13893
14703
 
14704
+ if isinstance(category_datas, list):
14705
+ new_data = {}
14706
+ for category_data in category_datas:
14707
+ first_key = next(iter(category_data))
14708
+ new_data[first_key] = category_data
14709
+ category_datas = new_data
14710
+
13894
14711
  try:
13895
14712
  for category_data in category_datas.values():
13896
14713
  for attribute_key, value in category_data.items():
@@ -13911,7 +14728,7 @@ class OTCS:
13911
14728
  current_dict = set_data
13912
14729
  elif isinstance(set_data, list):
13913
14730
  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)
14731
+ self.logger.debug("Target dict is row %d of multi-line set attribute.", row_number)
13915
14732
  if row_number > len(set_data):
13916
14733
  self.logger.debug("Add rows up to %d of multi-line set attribute...", row_number)
13917
14734
  for _ in range(row_number - len(set_data)):
@@ -13927,6 +14744,7 @@ class OTCS:
13927
14744
  current_dict[attribute_name] = value
13928
14745
  except Exception as e:
13929
14746
  self.logger.error("Something went wrong while filling the data! Error -> %s", str(e))
14747
+ return None
13930
14748
 
13931
14749
  return result_dict
13932
14750
 
@@ -14311,7 +15129,7 @@ class OTCS:
14311
15129
 
14312
15130
  Args:
14313
15131
  node_id (int):
14314
- The node ID of the Extended ECM workspace template.
15132
+ The node ID of the Business Workspace template.
14315
15133
 
14316
15134
  Returns:
14317
15135
  dict | None:
@@ -16679,7 +17497,7 @@ class OTCS:
16679
17497
  "enabled": status,
16680
17498
  }
16681
17499
 
16682
- request_url = self.config()["aiUrl"] + "/{}".format(workspace_id)
17500
+ request_url = self.config()["aiNodesUrl"] + "/{}".format(workspace_id)
16683
17501
  request_header = self.request_form_header()
16684
17502
 
16685
17503
  if status is True:
@@ -16708,6 +17526,181 @@ class OTCS:
16708
17526
 
16709
17527
  # end method definition
16710
17528
 
17529
+ def aviator_chat(
17530
+ self, context: str | None, messages: list[dict], where: list[dict] | None = None, inline_citation: bool = True
17531
+ ) -> dict | None:
17532
+ """Process a chat interaction with Content Aviator.
17533
+
17534
+ Args:
17535
+ context (str | None):
17536
+ Context for the current conversation. This includes the text chunks
17537
+ provided by the RAG pipeline.
17538
+ messages (list[dict]):
17539
+ List of messages from conversation history.
17540
+ Format example:
17541
+ [
17542
+ {
17543
+ "author": "user", "content": "Summarize this workspace, please."
17544
+ },
17545
+ {
17546
+ "author": "ai", "content": "..."
17547
+ }
17548
+ ]
17549
+ where (list):
17550
+ Metadata name/value pairs for the query.
17551
+ Could be used to specify workspaces, documents, or other criteria in the future.
17552
+ Values need to match those passed as metadata to the embeddings API.
17553
+ Format example:
17554
+ [
17555
+ {"workspaceID":"38673"},
17556
+ {"documentID":"38458"},
17557
+ ]
17558
+ inline_citation (bool, optional):
17559
+ Whether or not inline citations should be used in the response. Default is True.
17560
+
17561
+ Returns:
17562
+ dict:
17563
+ Conversation status
17564
+
17565
+ Example:
17566
+ {
17567
+ '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.',
17568
+ 'references': [
17569
+ {
17570
+ 'chunks': [
17571
+ {
17572
+ 'citation': None,
17573
+ 'content': ['16. 1 Basic principles 16.'],
17574
+ 'distance': 0.262610273676197,
17575
+ 'source': 'Similarity'
17576
+ }
17577
+ ],
17578
+ 'distance': 0.262610273676197,
17579
+ 'metadata': {
17580
+ 'content': {
17581
+ 'chunks': ['16. 1 Basic principles 16.'],
17582
+ 'source': 'Similarity'
17583
+ },
17584
+ 'documentID': '39004',
17585
+ 'workspaceID': '38673'
17586
+ }
17587
+ },
17588
+ {
17589
+ 'chunks': [
17590
+ {
17591
+ 'citation': None,
17592
+ 'content': ['16. 1.'],
17593
+ 'distance': 0.284182507756566,
17594
+ 'source': 'Similarity'
17595
+ }
17596
+ ],
17597
+ 'distance': 0.284182507756566,
17598
+ 'metadata': {
17599
+ 'content': {
17600
+ 'chunks': ['16. 1.'],
17601
+ 'source': 'Similarity'
17602
+ },
17603
+ 'documentID': '38123',
17604
+ 'workspaceID': '38673'
17605
+ }
17606
+ }
17607
+ ],
17608
+ 'context': 'Tool "get_context" called with arguments {"query":"Tell me about the calibration equipment"} and returned:',
17609
+ 'queryMetadata': {
17610
+ 'originalQuery': 'Tell me about the calibration equipment',
17611
+ 'usedQuery': 'Tell me about the calibration equipment'
17612
+ }
17613
+ }
17614
+
17615
+
17616
+
17617
+ """
17618
+
17619
+ request_url = self.config()["aiChatUrl"]
17620
+ request_header = self.request_form_header()
17621
+
17622
+ chat_data = {}
17623
+ if where:
17624
+ chat_data["where"] = where
17625
+
17626
+ chat_data["context"] = context
17627
+ chat_data["messages"] = messages
17628
+ # "synonyms": self.config()["synonyms"],
17629
+ chat_data["inlineCitation"] = inline_citation
17630
+
17631
+ return self.do_request(
17632
+ url=request_url,
17633
+ method="POST",
17634
+ headers=request_header,
17635
+ # data=chat_data,
17636
+ data={"body": json.dumps(chat_data)},
17637
+ timeout=None,
17638
+ failure_message="Failed to chat with Content Aviator",
17639
+ )
17640
+
17641
+ # end method definition
17642
+
17643
+ def aviator_context(
17644
+ self, query: str, threshold: float = 0.5, limit: int = 10, data: list | None = None
17645
+ ) -> dict | None:
17646
+ """Get context based on the query text from Aviator's vector database.
17647
+
17648
+ Results are text-chunks and they will be permission-checked for the authenticated user.
17649
+
17650
+ Args:
17651
+ query (str):
17652
+ The query text to search for similar text chunks.
17653
+ threshold (float, optional):
17654
+ Similarity threshold between 0 and 1. Default is 0.5.
17655
+ limit (int, optional):
17656
+ Maximum number of results to return. Default is 10.
17657
+ data (list | None, optional):
17658
+ Additional data to pass to the embeddings API. Defaults to None.
17659
+ This can include metadata for filtering the results.
17660
+
17661
+ Returns:
17662
+ dict | None:
17663
+ The response from the embeddings API or None if the request fails.
17664
+
17665
+ """
17666
+
17667
+ request_url = self.config()["aiContextUrl"]
17668
+ request_header = self.request_form_header()
17669
+
17670
+ if not query:
17671
+ self.logger.error("Query text is required for getting context from Content Aviator!")
17672
+ return None
17673
+
17674
+ context_post_body = {
17675
+ "query": query,
17676
+ "threshold": threshold,
17677
+ "limit": limit,
17678
+ }
17679
+ if data:
17680
+ context_post_body["data"] = data
17681
+ else:
17682
+ context_post_body["data"] = []
17683
+
17684
+ self.logger.debug(
17685
+ "Get context from Content Aviator for query -> '%s' (threshold: %f, limit: %d); calling -> %s",
17686
+ query,
17687
+ threshold,
17688
+ limit,
17689
+ request_url,
17690
+ )
17691
+
17692
+ return self.do_request(
17693
+ url=request_url,
17694
+ method="POST",
17695
+ headers=request_header,
17696
+ # data={"body": json.dumps(context_post_body)},
17697
+ data=context_post_body,
17698
+ timeout=None,
17699
+ failure_message="Failed to retrieve context from Content Aviator",
17700
+ )
17701
+
17702
+ # end method definition
17703
+
16711
17704
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="traverse_node")
16712
17705
  def traverse_node(
16713
17706
  self,
@@ -16788,7 +17781,8 @@ class OTCS:
16788
17781
  for subnode in subnodes:
16789
17782
  subnode_id = self.get_result_value(response=subnode, key="id")
16790
17783
  subnode_name = self.get_result_value(response=subnode, key="name")
16791
- self.logger.info("Traversing node -> '%s' (%s)", subnode_name, str(subnode_id))
17784
+ subnode_type = self.get_result_value(response=subnode, key="type")
17785
+ self.logger.info("Traversing %s node -> '%s' (%s)", subnode_type, subnode_name, subnode_id)
16792
17786
  # Recursive call for current subnode:
16793
17787
  result = self.traverse_node(
16794
17788
  node=subnode,
@@ -16866,7 +17860,13 @@ class OTCS:
16866
17860
  task_queue.put((subnode, 0, traversal_data))
16867
17861
 
16868
17862
  def traverse_node_worker() -> None:
16869
- """Work on queue.
17863
+ """Work on a shared queue.
17864
+
17865
+ Loops over these steps:
17866
+ 1. Get node from queue
17867
+ 2. Execute all executables for that node
17868
+ 3. If node is a container and executables indicate to traverse,
17869
+ then enqueue all subnodes
16870
17870
 
16871
17871
  Returns:
16872
17872
  None
@@ -16903,6 +17903,17 @@ class OTCS:
16903
17903
 
16904
17904
  # Run all executables
16905
17905
  for executable in executables or []:
17906
+ # The executables are functions or method from outside this class.
17907
+ # They need to return a tuple of two boolean values:
17908
+ # (result_success, result_traverse)
17909
+ # result_success indicates if the executable was successful (True)
17910
+ # or not (False). If False, the execution of the executables list
17911
+ # is stopped.
17912
+ # result_traverse indicates if the traversal should continue
17913
+ # into subnodes (True) or not (False).
17914
+ # If at least one executable returns result_traverse = True,
17915
+ # then the traversal into subnodes will be done (if the node is a container).
17916
+ # As this code is from outside this class, we better catch exceptions:
16906
17917
  try:
16907
17918
  result_success, result_traverse = executable(
16908
17919
  node=node,
@@ -17877,7 +18888,8 @@ class OTCS:
17877
18888
  Defaults to None = filter not active.
17878
18889
  filter_subtypes (list | None, optional):
17879
18890
  Additive filter criterium for item type.
17880
- Defaults to None = filter not active.
18891
+ Defaults to None = filter not active. filter_subtypes = [] is different from None!
18892
+ If an empty list is provided, the filter is effectively always True.
17881
18893
  filter_category (str | None, optional):
17882
18894
  Additive filter criterium for existence of a category on the node.
17883
18895
  The value of filter_category is the name of the category
@@ -17910,7 +18922,7 @@ class OTCS:
17910
18922
  self.logger.error("Illegal node - cannot apply filter!")
17911
18923
  return False
17912
18924
 
17913
- if filter_subtypes and node["type"] not in filter_subtypes:
18925
+ if filter_subtypes is not None and node["type"] not in filter_subtypes:
17914
18926
  self.logger.debug(
17915
18927
  "Node type -> '%s' is not in filter node types -> %s. Node -> '%s' failed filter test.",
17916
18928
  node["type"],
@@ -18623,6 +19635,8 @@ class OTCS:
18623
19635
  def load_items(
18624
19636
  self,
18625
19637
  node_id: int,
19638
+ workspaces: bool = True,
19639
+ items: bool = True,
18626
19640
  filter_workspace_depth: int | None = None,
18627
19641
  filter_workspace_subtypes: list | None = None,
18628
19642
  filter_workspace_category: str | None = None,
@@ -18647,6 +19661,12 @@ class OTCS:
18647
19661
  Args:
18648
19662
  node_id (int):
18649
19663
  The root Node ID the traversal should start at.
19664
+ workspaces (bool, optional):
19665
+ If True, workspaces are included in the data frame.
19666
+ Defaults to True.
19667
+ items (bool, optional):
19668
+ If True, document items are included in the data frame.
19669
+ Defaults to True.
18650
19670
  filter_workspace_depth (int | None, optional):
18651
19671
  Additive filter criterium for workspace path depth.
18652
19672
  Defaults to None = filter not active.
@@ -18695,9 +19715,30 @@ class OTCS:
18695
19715
  dict:
18696
19716
  Stats with processed and traversed counters.
18697
19717
 
19718
+ Side Effects:
19719
+ The resulting data frame is stored in self._data. It will have the following columns:
19720
+ - type which is either "item" or "workspace"
19721
+ - workspace_type
19722
+ - workspace_id
19723
+ - workspace_name
19724
+ - workspace_description
19725
+ - workspace_outer_path
19726
+ - workspace_<cat_id>_<attr_id> for each workspace attribute if workspace_metadata is True
19727
+ - item_id
19728
+ - item_type
19729
+ - item_name
19730
+ - item_description
19731
+ - item_path
19732
+ - item_download_name
19733
+ - item_mime_type
19734
+ - item_url
19735
+ - item_<cat_id>_<attr_id> for each item attribute if item_metadata is True
19736
+ - item_cat_<cat_id>_<attr_id> for each item attribute if item_metadata is True and self._use_numeric_category_identifier is True
19737
+ - item_cat_<cat_name>_<attr_name> for each item attribute if item_metadata is True and self._use_numeric_category_identifier is False
19738
+
18698
19739
  """
18699
19740
 
18700
- # Initiaze download threads for this subnode:
19741
+ # Initiaze download threads for document items:
18701
19742
  download_threads = []
18702
19743
 
18703
19744
  def check_node_exclusions(node: dict, **kwargs: dict) -> tuple[bool, bool]:
@@ -18718,15 +19759,14 @@ class OTCS:
18718
19759
 
18719
19760
  """
18720
19761
 
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)
19762
+ # Get the list of node IDs to exclude from the keyword arguments.
19763
+ # If not provided, use an empty list as default which means no exclusions.
19764
+ exclude_node_ids = kwargs.get("exclude_node_ids") or []
18725
19765
 
18726
19766
  node_id = self.get_result_value(response=node, key="id")
18727
19767
  node_name = self.get_result_value(response=node, key="name")
18728
19768
 
18729
- if node_id and exclude_node_ids is not None and (node_id in exclude_node_ids):
19769
+ if node_id and (node_id in exclude_node_ids):
18730
19770
  self.logger.info(
18731
19771
  "Node -> '%s' (%s) is in exclusion list. Skip traversal of this node.",
18732
19772
  node_name,
@@ -18753,13 +19793,36 @@ class OTCS:
18753
19793
 
18754
19794
  """
18755
19795
 
19796
+ # This should actually not happen as the caller should
19797
+ # check if workspaces are requested before calling this function.
19798
+ if not workspaces:
19799
+ # Success = False, Traverse = True
19800
+ return (False, True)
19801
+
18756
19802
  traversal_data = kwargs.get("traversal_data")
18757
19803
  filter_workspace_data = kwargs.get("filter_workspace_data")
18758
19804
  control_flags = kwargs.get("control_flags")
18759
19805
 
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
19806
+ if not traversal_data:
19807
+ self.logger.error(
19808
+ "Missing keyword argument 'traversal_data' for executable 'check_node_workspace' in node traversal!"
19809
+ )
19810
+ # Success = False, Traverse = False
19811
+ return (False, False)
19812
+
19813
+ if not filter_workspace_data:
19814
+ self.logger.error(
19815
+ "Missing keyword argument 'filter_workspace_data' for executable 'check_node_workspace' in node traversal!"
19816
+ )
19817
+ # Success = False, Traverse = False
19818
+ return (False, False)
19819
+
19820
+ if not control_flags:
19821
+ self.logger.error(
19822
+ "Missing keyword argument 'control_flags' for executable 'check_node_workspace' in node traversal!"
19823
+ )
19824
+ # Success = False, Traverse = False
19825
+ return (False, False)
18763
19826
 
18764
19827
  node_id = self.get_result_value(response=node, key="id")
18765
19828
  node_name = self.get_result_value(response=node, key="name")
@@ -18767,10 +19830,10 @@ class OTCS:
18767
19830
  node_type = self.get_result_value(response=node, key="type")
18768
19831
 
18769
19832
  #
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.
19833
+ # 1. Check if the traversal is already inside a workspace. Then we can skip
19834
+ # the workspace processing as we currently don't support sub-workspaces.
18772
19835
  #
18773
- workspace_id = traversal_data["workspace_id"]
19836
+ workspace_id = traversal_data.get("workspace_id")
18774
19837
  if workspace_id:
18775
19838
  self.logger.debug(
18776
19839
  "Found folder or workspace -> '%s' (%s) inside workspace with ID -> %d. So this container cannot be a workspace.",
@@ -18801,7 +19864,7 @@ class OTCS:
18801
19864
  categories = None
18802
19865
 
18803
19866
  #
18804
- # 3. Apply the defined filters to the current node to see
19867
+ # 3. Apply the defined workspace filters to the current node to see
18805
19868
  # if we want to 'interpret' it as a workspace
18806
19869
  #
18807
19870
  # See if it is a node that we want to interpret as a workspace.
@@ -18818,6 +19881,13 @@ class OTCS:
18818
19881
  filter_category=filter_workspace_data["filter_workspace_category"],
18819
19882
  filter_attributes=filter_workspace_data["filter_workspace_attributes"],
18820
19883
  ):
19884
+ self.logger.debug(
19885
+ "Node -> '%s' (%s) did not match workspace filter -> %s",
19886
+ node_name,
19887
+ node_id,
19888
+ str(filter_workspace_data),
19889
+ )
19890
+
18821
19891
  # Success = False, Traverse = True
18822
19892
  return (False, True)
18823
19893
 
@@ -18831,7 +19901,7 @@ class OTCS:
18831
19901
  #
18832
19902
  # 4. Create the data frame row from the node / traversal data:
18833
19903
  #
18834
- row = {}
19904
+ row = {"type": "workspace"}
18835
19905
  row["workspace_type"] = node_type
18836
19906
  row["workspace_id"] = node_id
18837
19907
  row["workspace_name"] = node_name
@@ -18855,7 +19925,7 @@ class OTCS:
18855
19925
  traversal_data["workspace_description"] = node_description
18856
19926
  self.logger.debug("Updated traversal data -> %s", str(traversal_data))
18857
19927
 
18858
- # Success = True, Traverse = False
19928
+ # Success = True, Traverse = True
18859
19929
  # We have traverse = True because we need to
18860
19930
  # keep traversing into the workspace folders.
18861
19931
  return (True, True)
@@ -18882,8 +19952,16 @@ class OTCS:
18882
19952
  filter_item_data = kwargs.get("filter_item_data")
18883
19953
  control_flags = kwargs.get("control_flags")
18884
19954
 
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!")
19955
+ if not traversal_data:
19956
+ self.logger.error("Missing keyword argument 'traversal_data' for executable in node item traversal!")
19957
+ return (False, False)
19958
+
19959
+ if not filter_item_data:
19960
+ self.logger.error("Missing keyword argument 'filter_item_data' for executable in node item traversal!")
19961
+ return (False, False)
19962
+
19963
+ if not control_flags:
19964
+ self.logger.error("Missing keyword argument 'control_flags' for executable in node item traversal!")
18887
19965
  return (False, False)
18888
19966
 
18889
19967
  node_id = self.get_result_value(response=node, key="id")
@@ -18918,7 +19996,7 @@ class OTCS:
18918
19996
  categories = None
18919
19997
 
18920
19998
  #
18921
- # 2. Apply the defined filters to the current node to see
19999
+ # 2. Apply the defined item filters to the current node to see
18922
20000
  # if we want to add it to the data frame as an item.
18923
20001
  #
18924
20002
  # If filter_item_in_workspace is false, then documents
@@ -18935,10 +20013,17 @@ class OTCS:
18935
20013
  filter_category=filter_item_data["filter_item_category"],
18936
20014
  filter_attributes=filter_item_data["filter_item_attributes"],
18937
20015
  ):
20016
+ self.logger.debug(
20017
+ "Node -> '%s' (%s) did not match item filter -> %s",
20018
+ node_name,
20019
+ node_id,
20020
+ str(filter_item_data),
20021
+ )
20022
+
18938
20023
  # Success = False, Traverse = True
18939
20024
  return (False, True)
18940
20025
 
18941
- # We only consider documents that are inside the defined "workspaces":
20026
+ # Debug output where we found the item (inside or outside of workspace):
18942
20027
  if workspace_id:
18943
20028
  self.logger.debug(
18944
20029
  "Found %s item -> '%s' (%s) in depth -> %s inside workspace -> '%s' (%s).",
@@ -18971,15 +20056,19 @@ class OTCS:
18971
20056
  if control_flags["download_documents"] and (
18972
20057
  not os.path.exists(file_path) or not control_flags["skip_existing_downloads"]
18973
20058
  ):
18974
- #
18975
- # Start anasynchronous Download Thread:
18976
- #
20059
+ mime_type = self.get_result_value(response=node, key="mime_type")
20060
+ extract_after_download = mime_type == "application/x-zip-compressed" and extract_zip
18977
20061
  self.logger.debug(
18978
- "Downloading file -> '%s'...",
20062
+ "Downloading document -> '%s' (%s) to temp file -> '%s'%s...",
20063
+ node_name,
20064
+ mime_type,
18979
20065
  file_path,
20066
+ " and extracting it after download" if extract_after_download else "",
18980
20067
  )
18981
20068
 
18982
- extract_after_download = node["mime_type"] == "application/x-zip-compressed" and extract_zip
20069
+ #
20070
+ # Start asynchronous Download Thread:
20071
+ #
18983
20072
  thread = threading.Thread(
18984
20073
  target=self.download_document_multi_threading,
18985
20074
  args=(node_id, file_path, extract_after_download),
@@ -18989,7 +20078,8 @@ class OTCS:
18989
20078
  download_threads.append(thread)
18990
20079
  else:
18991
20080
  self.logger.debug(
18992
- "File -> %s has been downloaded before or download is not requested. Skipping download...",
20081
+ "Document -> '%s' has been downloaded to file -> %s before or download is not requested. Skipping download...",
20082
+ node_name,
18993
20083
  file_path,
18994
20084
  )
18995
20085
  # end if document
@@ -18998,26 +20088,36 @@ class OTCS:
18998
20088
  # Construct a dictionary 'row' that we will add
18999
20089
  # to the resulting data frame:
19000
20090
  #
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
20091
+ row = {"type": "item"}
20092
+ if workspaces:
20093
+ # First we include some key workspace data to associate
20094
+ # the item with the workspace:
20095
+ row["workspace_type"] = workspace_type
20096
+ row["workspace_id"] = workspace_id
20097
+ row["workspace_name"] = workspace_name
20098
+ row["workspace_description"] = workspace_description
19008
20099
  # Then add item specific data:
19009
20100
  row["item_id"] = str(node_id)
19010
20101
  row["item_type"] = node_type
19011
20102
  row["item_name"] = node_name
19012
20103
  row["item_description"] = node_description
19013
- # We take the sub-path of the folder path inside the workspace
20104
+ row["item_path"] = []
20105
+ # We take the part of folder path which is inside the workspace
19014
20106
  # 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"] = []
20107
+ if (
20108
+ folder_path and workspace_name and workspace_name in folder_path
20109
+ ): # check if folder_path is not empty, this can happy if document items are the workspace items
20110
+ try:
20111
+ # Item path are the list elements after the item that is the workspace name:
20112
+ row["item_path"] = folder_path[folder_path.index(workspace_name) + 1 :]
20113
+ except ValueError:
20114
+ self.logger.warning(
20115
+ "Cannot find workspace name -> '%s' in folder path -> %s while processing -> '%s' (%s)!",
20116
+ workspace_name,
20117
+ folder_path,
20118
+ node_name,
20119
+ node_id,
20120
+ )
19021
20121
  row["item_download_name"] = str(node_id) if node_type == self.ITEM_TYPE_DOCUMENT else ""
19022
20122
  row["item_mime_type"] = (
19023
20123
  self.get_result_value(response=node, key="mime_type") if node_type == self.ITEM_TYPE_DOCUMENT else ""
@@ -19025,10 +20125,10 @@ class OTCS:
19025
20125
  # URL specific data:
19026
20126
  row["item_url"] = self.get_result_value(response=node, key="url") if node_type == self.ITEM_TYPE_URL else ""
19027
20127
  if item_metadata and categories and categories["results"]:
19028
- # Add columns for workspace node categories have been determined above.
20128
+ # Add columns for item node categories have been determined above.
19029
20129
  self.add_attribute_columns(row=row, categories=categories, prefix="item_cat_")
19030
20130
 
19031
- # Now we add the row to the Pandas Data Frame in the Data class:
20131
+ # Now we add the item row to the Pandas Data Frame in the Data class:
19032
20132
  self.logger.info(
19033
20133
  "Adding %s -> '%s' (%s) to data frame...",
19034
20134
  "document" if node_type == self.ITEM_TYPE_DOCUMENT else "URL",
@@ -19038,7 +20138,9 @@ class OTCS:
19038
20138
  with self._data.lock():
19039
20139
  self._data.append(row)
19040
20140
 
19041
- return True
20141
+ # Success = True, Traverse = False
20142
+ # We have traverse = False because document or URL items have no sub-items.
20143
+ return (True, False)
19042
20144
 
19043
20145
  # end check_node_item()
19044
20146
 
@@ -19076,19 +20178,37 @@ class OTCS:
19076
20178
  "extract_zip": extract_zip,
19077
20179
  }
19078
20180
 
20181
+ #
20182
+ # Define the list of executables to call for each node:
20183
+ #
20184
+ executables = []
20185
+ if workspaces:
20186
+ executables.append(check_node_workspace)
20187
+ if items:
20188
+ executables.append(check_node_item)
20189
+ if not executables:
20190
+ self.logger.error("Neither workspaces nor items are requested to be loaded. Nothing to do!")
20191
+ return None
20192
+
19079
20193
  #
19080
20194
  # Start the traversal of the nodes:
19081
20195
  #
19082
20196
  result = self.traverse_node_parallel(
19083
20197
  node=node_id,
20198
+ # For each node we call these executables in this order to check if
20199
+ # the node should be added to the resulting data frame:
19084
20200
  executables=[check_node_exclusions, check_node_workspace, check_node_item],
20201
+ workers=workers, # number of worker threads
19085
20202
  exclude_node_ids=exclude_node_ids,
19086
20203
  filter_workspace_data=filter_workspace_data,
19087
20204
  filter_item_data=filter_item_data,
19088
20205
  control_flags=control_flags,
19089
- workers=workers, # number of worker threads
19090
20206
  )
19091
20207
 
20208
+ # Wait for all download threads to complete:
20209
+ for thread in download_threads:
20210
+ thread.join()
20211
+
19092
20212
  return result
19093
20213
 
19094
20214
  # end method definition
@@ -19177,7 +20297,7 @@ class OTCS:
19177
20297
  }
19178
20298
  if message_override:
19179
20299
  message.update(message_override)
19180
- self.logger.info(
20300
+ self.logger.debug(
19181
20301
  "Start Content Aviator embedding on -> '%s' (%s), type -> %s, crawl -> %s, wait for completion -> %s, workspaces -> %s, documents -> %s, images -> %s",
19182
20302
  node_properties["name"],
19183
20303
  node_properties["id"],
@@ -19188,7 +20308,7 @@ class OTCS:
19188
20308
  document_metadata,
19189
20309
  images,
19190
20310
  )
19191
- self.logger.debug("Sending WebSocket message -> %s", message)
20311
+ self.logger.debug("Sending WebSocket message -> %s...", message)
19192
20312
  await websocket.send(message=json.dumps(message))
19193
20313
 
19194
20314
  # Continuously listen for messages
@@ -19295,3 +20415,170 @@ class OTCS:
19295
20415
  return success
19296
20416
 
19297
20417
  # end method definition
20418
+
20419
+ def _get_document_template_raw(self, workspace_id: int) -> ET.Element | None:
20420
+ """Get the raw template XML payload from a workspace.
20421
+
20422
+ Args:
20423
+ workspace_id (int):
20424
+ The ID of the workspace to generate the document from.
20425
+
20426
+ Returns:
20427
+ ET.Element | None:
20428
+ The XML Element with the payload to initiate a document generation, or None if an error occurred
20429
+
20430
+ """
20431
+
20432
+ request_url = self.config()["csUrl"]
20433
+
20434
+ request_header = self.request_form_header()
20435
+ request_header["referer"] = "http://localhost"
20436
+
20437
+ data = {
20438
+ "func": "xecmpfdocgen.PowerDocsPayload",
20439
+ "wsId": str(workspace_id),
20440
+ "hideHeader": "true",
20441
+ "source": "CreateDocument",
20442
+ }
20443
+
20444
+ self.logger.debug(
20445
+ "Get document templates for workspace with ID -> %d; calling -> %s",
20446
+ workspace_id,
20447
+ request_url,
20448
+ )
20449
+
20450
+ response = self.do_request(
20451
+ url=request_url,
20452
+ method="POST",
20453
+ headers=request_header,
20454
+ data=data,
20455
+ timeout=None,
20456
+ failure_message="Failed to get document templates for workspace with ID -> {}".format(workspace_id),
20457
+ parse_request_response=False,
20458
+ )
20459
+
20460
+ if response is None:
20461
+ return None
20462
+
20463
+ try:
20464
+ text = response.text
20465
+ match = re.search(r'<textarea[^>]*name=["\']documentgeneration["\'][^>]*>(.*?)</textarea>', text, re.DOTALL)
20466
+ textarea_content = match.group(1).strip() if match else ""
20467
+ textarea_content = html.unescape(textarea_content)
20468
+
20469
+ # Load Payload into XML object
20470
+ # payload is an XML formatted string, load it into an XML object for further processing
20471
+
20472
+ root = ET.Element("documentgeneration", format="pdf")
20473
+ root.append(ET.fromstring(textarea_content))
20474
+ except (ET.ParseError, AttributeError) as exc:
20475
+ self.logger.error(
20476
+ "Cannot parse document template XML payload for workspace with ID -> %d! Error -> %s",
20477
+ workspace_id,
20478
+ exc,
20479
+ )
20480
+ return None
20481
+ else:
20482
+ return root
20483
+
20484
+ # end method definition
20485
+
20486
+ def get_document_template_names(self, workspace_id: int, root: ET.Element | None = None) -> list[str] | None:
20487
+ """Get the list of available template names from a workspace.
20488
+
20489
+ Args:
20490
+ workspace_id (int):
20491
+ The ID of the workspace to generate the document from.
20492
+ root (ET.Element | None, optional):
20493
+ The XML Element with the payload to initiate a document generation.
20494
+
20495
+ Returns:
20496
+ list[str] | None:
20497
+ A list of template names available in the workspace, or None if an error occurred.
20498
+
20499
+ """
20500
+
20501
+ if root is None:
20502
+ root = self._get_document_template_raw(workspace_id=workspace_id)
20503
+ if root is None:
20504
+ self.logger.error(
20505
+ "Cannot get document templates for workspace with ID -> %d",
20506
+ workspace_id,
20507
+ )
20508
+ return None
20509
+ template_names = [item.text for item in root.findall("startup/processing/templates/template")]
20510
+
20511
+ return template_names
20512
+
20513
+ # end method definition
20514
+
20515
+ def get_document_template(
20516
+ self, workspace_id: int, template_name: str, input_values: dict | None = None
20517
+ ) -> str | None:
20518
+ """Get the template XML payload from a workspace and a given template name.
20519
+
20520
+ Args:
20521
+ workspace_id (int):
20522
+ The ID of the workspace to generate the document from.
20523
+ template_name (str):
20524
+ The name of the template to use for document generation.
20525
+ input_values (dict | None, optional):
20526
+ A dictionary with input values to replace in the template.
20527
+
20528
+ Returns:
20529
+ str | None:
20530
+ The XML string with the payload to initiate a document generation, or None if an error occurred.
20531
+
20532
+ """
20533
+
20534
+ root = self._get_document_template_raw(workspace_id=workspace_id)
20535
+ if root is None:
20536
+ self.logger.error(
20537
+ "Cannot get document template for workspace with ID -> %d",
20538
+ workspace_id,
20539
+ )
20540
+ return None
20541
+
20542
+ template_names = self.get_document_template_names(workspace_id=workspace_id, root=root)
20543
+
20544
+ if template_name not in template_names:
20545
+ self.logger.error(
20546
+ "Template name -> '%s' not found in workspace with ID -> %d! Available templates are: %s",
20547
+ template_name,
20548
+ workspace_id,
20549
+ ", ".join(template_names),
20550
+ )
20551
+ return None
20552
+
20553
+ # remove startup/processing
20554
+ startup = root.find("startup")
20555
+ # Find the existing application element and update its sysid attribute
20556
+ application = startup.find("application")
20557
+ application.set("sysid", "3adfd3a4-718f-4b9c-ac93-72efbcdf17f1")
20558
+
20559
+ processing = startup.find("processing")
20560
+
20561
+ # Clear processing information
20562
+ processing.clear()
20563
+
20564
+ modus = ET.SubElement(processing, "modus")
20565
+ modus.text = "local"
20566
+ editor = ET.SubElement(processing, "editor")
20567
+ editor.text = "false"
20568
+ template = ET.SubElement(processing, "template", type="Name")
20569
+ template.text = template_name
20570
+ channel = ET.SubElement(processing, "channel")
20571
+ channel.text = "save"
20572
+
20573
+ # Add static query information for userId and asOfDate
20574
+ if input_values:
20575
+ query = ET.SubElement(startup, "query", type="value")
20576
+ input_element = ET.SubElement(query, "input")
20577
+
20578
+ for column, value in input_values.items():
20579
+ value_element = ET.SubElement(input_element, "value", column=column)
20580
+ value_element.text = value
20581
+
20582
+ payload = ET.tostring(root, encoding="utf8").decode("utf8")
20583
+
20584
+ return payload