pyxecm 2.0.3__py3-none-any.whl → 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

pyxecm/otcs.py CHANGED
@@ -27,10 +27,12 @@ import threading
27
27
  import time
28
28
  import urllib.parse
29
29
  import zipfile
30
+ from concurrent.futures import ThreadPoolExecutor
30
31
  from datetime import datetime, timezone
31
32
  from functools import cache
32
33
  from http import HTTPStatus
33
34
  from importlib.metadata import version
35
+ from queue import Empty, LifoQueue, Queue
34
36
 
35
37
  import requests
36
38
  import websockets
@@ -148,6 +150,16 @@ class OTCS:
148
150
  ITEM_TYPE_WORKFLOW_MAP = 128
149
151
  ITEM_TYPE_WORKFLOW_STATUS = 190
150
152
 
153
+ CONTAINER_ITEM_TYPES = [
154
+ ITEM_TYPE_FOLDER,
155
+ ITEM_TYPE_BUSINESS_WORKSPACE,
156
+ ITEM_TYPE_COMPOUND_DOCUMENT,
157
+ ITEM_TYPE_CLASSIFICATION,
158
+ VOLUME_TYPE_ENTERPRISE_WORKSPACE,
159
+ VOLUME_TYPE_CLASSIFICATION_VOLUME,
160
+ VOLUME_TYPE_CONTENT_SERVER_DOCUMENT_TEMPLATES,
161
+ ]
162
+
151
163
  PERMISSION_TYPES = [
152
164
  "see",
153
165
  "see_contents",
@@ -166,6 +178,10 @@ class OTCS:
166
178
  "public",
167
179
  "custom",
168
180
  ]
181
+
182
+ # The maximum length of an item name in OTCS:
183
+ MAX_ITEM_NAME_LENGTH = 248
184
+
169
185
  _config: dict
170
186
  _otcs_ticket = None
171
187
  _otds_ticket = None
@@ -183,6 +199,42 @@ class OTCS:
183
199
  ) # only 1 thread should handle the re-authentication
184
200
  _session_lock = threading.Lock()
185
201
 
202
+ @classmethod
203
+ def cleanse_item_name(cls, item_name: str, max_length: int | None = None) -> str:
204
+ """Cleanse the given name of an OTCS item.
205
+
206
+ Control for forbidden characters and check the item name length.
207
+
208
+ Args:
209
+ item_name (str):
210
+ The item name to cleanse.
211
+ max_length (int, optional):
212
+ A specific maximum length for custom cases.
213
+ If not provided we will use the default OTCS.MAX_ITEM_NAME_LENGTH.
214
+
215
+ Returns:
216
+ str:
217
+ The cleansed item name.
218
+
219
+ """
220
+
221
+ # If no custom max length is given we use the default:
222
+ if max_length is None:
223
+ max_length = OTCS.MAX_ITEM_NAME_LENGTH
224
+
225
+ # Item names for sure are not allowed to have ":":
226
+ item_name = item_name.replace(":", "")
227
+ # Item names for sure should not have leading or trailing spaces:
228
+ item_name = item_name.strip()
229
+ # Truncate the item name to 248 characters which is the maximum
230
+ # allowed length in Content Server
231
+ if len(item_name) > max_length:
232
+ item_name = item_name[:max_length]
233
+
234
+ return item_name
235
+
236
+ # end method definition
237
+
186
238
  @classmethod
187
239
  def date_is_newer(cls, date_old: str, date_new: str) -> bool:
188
240
  """Compare two dates, typically create or modification dates.
@@ -461,6 +513,7 @@ class OTCS:
461
513
  self._semaphore = threading.BoundedSemaphore(value=thread_number)
462
514
  self._last_session_renewal = 0
463
515
  self._use_numeric_category_identifier = use_numeric_category_identifier
516
+ self._executor = ThreadPoolExecutor(max_workers=thread_number)
464
517
 
465
518
  # end method definition
466
519
 
@@ -734,6 +787,21 @@ class OTCS:
734
787
 
735
788
  # end method definition
736
789
 
790
+ def clear_data(self) -> Data:
791
+ """Reset the data object to an empty data frame.
792
+
793
+ Returns:
794
+ Data:
795
+ Newly initialized data object.
796
+
797
+ """
798
+
799
+ self._data = Data(logger=self.logger)
800
+
801
+ return self._data
802
+
803
+ # end method definition
804
+
737
805
  def request_form_header(self) -> dict:
738
806
  """Deliver the request header used for the CRUD REST API calls.
739
807
 
@@ -1457,7 +1525,7 @@ class OTCS:
1457
1525
  property_name: str = "properties",
1458
1526
  data_name: str = "data",
1459
1527
  ) -> list | None:
1460
- """Read an item value from the REST API response.
1528
+ """Read all values with a given key from the REST API response.
1461
1529
 
1462
1530
  This method handles the most common response structures delivered by the
1463
1531
  V2 REST API of Extended ECM. For more details, refer to the documentation at
@@ -1551,6 +1619,44 @@ class OTCS:
1551
1619
 
1552
1620
  # end method definition
1553
1621
 
1622
+ def get_result_values_iterator(
1623
+ self,
1624
+ response: dict,
1625
+ property_name: str = "properties",
1626
+ data_name: str = "data",
1627
+ ) -> iter:
1628
+ """Get an iterator object that can be used to traverse through OTCS responses.
1629
+
1630
+ This method handles the most common response structures delivered by the
1631
+ V2 REST API of Extended ECM. For more details, refer to the documentation at
1632
+ developer.opentext.com.
1633
+
1634
+ Args:
1635
+ response (dict):
1636
+ REST API response object.
1637
+ property_name (str, optional):
1638
+ Name of the sub-dictionary holding the actual values.
1639
+ Defaults to "properties".
1640
+ data_name (str, optional):
1641
+ Name of the sub-dictionary holding the data.
1642
+ Defaults to "data".
1643
+
1644
+ Returns:
1645
+ list | None:
1646
+ Value list of the item with the given key, or None if no value is found.
1647
+
1648
+ """
1649
+
1650
+ # First do some sanity checks:
1651
+ if not response:
1652
+ return
1653
+ if "results" not in response:
1654
+ return
1655
+
1656
+ yield from (item[data_name][property_name] for item in response["results"])
1657
+
1658
+ # end method definition
1659
+
1554
1660
  def is_configured(self) -> bool:
1555
1661
  """Check if the Content Server pod is configured to receive requests.
1556
1662
 
@@ -2036,63 +2142,141 @@ class OTCS:
2036
2142
 
2037
2143
  # end method definition
2038
2144
 
2039
- @cache
2040
- def get_user(self, name: str, user_type: int = 0, show_error: bool = False) -> dict | None:
2041
- """Look up an Content Server user based on the login name.
2145
+ def get_users(
2146
+ self,
2147
+ where_type: int = 0,
2148
+ where_name: str | None = None,
2149
+ where_first_name: str | None = None,
2150
+ where_last_name: str | None = None,
2151
+ where_business_email: str | None = None,
2152
+ query_string: str | None = None,
2153
+ sort: str | None = None,
2154
+ limit: int = 20,
2155
+ page: int = 1,
2156
+ show_error: bool = False,
2157
+ ) -> dict | None:
2158
+ """Get a Content Server users based on different criterias.
2159
+
2160
+ The criterias can be combined.
2042
2161
 
2043
2162
  Args:
2044
- name (str):
2045
- Name of the user (login).
2046
- user_type (int, optional):
2163
+ where_type (int, optional):
2047
2164
  Type ID of user:
2048
2165
  0 - Regular User
2049
2166
  17 - Service User
2050
2167
  Defaults to 0 -> (Regular User)
2051
-
2168
+ where_name (str | None = None):
2169
+ Name of the user (login).
2170
+ where_first_name (str | None = None):
2171
+ First name of the user.
2172
+ where_last_name (str | None = None):
2173
+ Last name of the user.
2174
+ where_business_email (str | None = None):
2175
+ Business email address of the user.
2176
+ query_string (str | None = None):
2177
+ Filters the results, returning the users with the specified query string
2178
+ in any of the following fields: log-in name, first name, last name, email address,
2179
+ and groups with the specified query string in the group name.
2180
+ NOTE: query cannot be used together with any combination of: where_name,
2181
+ where_first_name, where_last_name, where_business_email.
2182
+ The query value will be used to perform a search within the log-in name,
2183
+ first name, last name and email address properties for users and group name
2184
+ for groups to see if that value is contained within any of those properties.
2185
+ This differs from the user search that is performed in Classic UI where it
2186
+ searches for a specific property that begins with the value provided by the user.
2187
+ sort (str | None = None):
2188
+ Order by named column (Using prefixes such as sort=asc_name or sort=desc_name).
2189
+ Format can be sort = id, sort = name, sort = first_name, sort = last_name,
2190
+ sort = group_id, sort = mailaddress. If the prefix of asc or desc is not used
2191
+ then asc will be assumed.
2192
+ Default is None.
2193
+ limit (int, optional):
2194
+ The maximum number of results per page (internal default is 10). OTCS does
2195
+ not allow values > 20 so this method adjusts values > 20 to 20.
2196
+ page (int, optional):
2197
+ The page number to retrieve.
2052
2198
  show_error (bool, optional):
2053
2199
  If True, treat as an error if the user is not found. Defaults to False.
2054
2200
 
2055
2201
  Returns:
2056
2202
  dict | None:
2057
- User information as a dictionary, or None if the user is not found.
2203
+ User information as a dictionary, or None if the user could not be found
2204
+ (e.g., because it doesn't exist).
2058
2205
 
2059
2206
  Example:
2060
2207
  ```json
2061
2208
  {
2062
2209
  'collection': {
2063
- 'paging': {...},
2064
- 'sorting': {...}
2210
+ 'paging': {
2211
+ 'limit': 10,
2212
+ 'page': 1,
2213
+ 'page_total': 1,
2214
+ 'range_max': 1,
2215
+ 'range_min': 1,
2216
+ 'total_count': 1
2217
+ },
2218
+ 'sorting': {
2219
+ 'sort': [
2220
+ {
2221
+ 'key': 'sort',
2222
+ 'value': 'asc_id'
2223
+ }
2224
+ ]
2225
+ }
2065
2226
  },
2066
2227
  'links': {
2067
- 'data': {...}
2228
+ 'data': {
2229
+ 'self': {
2230
+ 'body': '',
2231
+ 'content_type': '',
2232
+ 'href': '/api/v2/members?where_first_name=Peter',
2233
+ 'method': 'GET',
2234
+ 'name': ''
2235
+ }
2236
+ }
2068
2237
  },
2069
2238
  'results': [
2070
2239
  {
2071
2240
  'data': {
2072
- 'birth_date': None,
2073
- 'business_email': 'pramos@M365x61936377.onmicrosoft.com',
2074
- 'business_fax': None,
2075
- 'business_phone': None,
2076
- 'cell_phone': None,
2077
- 'deleted': False,
2078
- 'display_language': None,
2079
- 'first_name': 'Peter',
2080
- 'gender': None,
2081
- 'group_id': 8006,
2082
- 'home_address_1': None,
2083
- 'home_address_2': None,
2084
- 'home_fax': None,
2085
- 'home_phone': None,
2086
- 'id': 8123,
2087
- 'initials': None,
2088
- 'last_name': 'Ramos',
2089
- 'middle_name': None,
2090
- 'name': 'pramos',
2091
- 'name_formatted': 'Peter Ramos',
2092
- 'photo_id': 13981,
2093
- 'photo_url': 'api/v1/members/8123/photo?v=13981.1',
2094
- 'type': 0,
2095
- 'type_name': 'User'
2241
+ 'properties': {
2242
+ 'birth_date': None,
2243
+ 'business_email': 'pramos@M365x61936377.onmicrosoft.com',
2244
+ 'business_fax': None,
2245
+ 'business_phone': None,
2246
+ 'cell_phone': None,
2247
+ 'deleted': False,
2248
+ 'display_language': None,
2249
+ 'first_name': 'Peter',
2250
+ 'gender': None,
2251
+ 'group_id': 8006,
2252
+ 'home_address_1': None,
2253
+ 'home_address_2': None,
2254
+ 'home_fax': None,
2255
+ 'home_phone': None,
2256
+ 'id': 8123,
2257
+ 'initials': None,
2258
+ 'last_name': 'Ramos',
2259
+ 'middle_name': None,
2260
+ 'name': 'pramos',
2261
+ 'name_formatted': 'Peter Ramos',
2262
+ 'office_location': None,
2263
+ 'pager': None,
2264
+ 'personal_email': None,
2265
+ 'photo_id': 13981,
2266
+ 'photo_url': 'api/v1/members/8123/photo?v=13981.1',
2267
+ 'privilege_content_manager': False,
2268
+ 'privilege_grant_discovery': False,
2269
+ 'privilege_login': True,
2270
+ 'privilege_modify_groups': False,
2271
+ 'privilege_modify_users': False,
2272
+ 'privilege_public_access': True,
2273
+ 'privilege_system_admin_rights': False,
2274
+ 'privilege_user_admin_rights': False,
2275
+ 'time_zone': -1,
2276
+ 'title': 'Maintenance Planner',
2277
+ 'type': 0,
2278
+ 'type_name': 'User'
2279
+ }
2096
2280
  }
2097
2281
  }
2098
2282
  ]
@@ -2105,17 +2289,45 @@ class OTCS:
2105
2289
 
2106
2290
  """
2107
2291
 
2108
- # Add query parameters (these are NOT passed via JSon body!)
2109
- # type = 0 ==> regular User
2110
- query = {"where_type": user_type, "where_name": name}
2292
+ # Add query parameters (embedded in the URL)
2293
+ # Using type = 0 for OTCS groups or type = 17 for service user:
2294
+ query = {}
2295
+ filter_string = " type -> 'service user'" if where_type == 17 else ""
2296
+ query["where_type"] = where_type
2297
+ if where_name:
2298
+ query["where_name"] = where_name
2299
+ filter_string += " login name -> '{}'".format(where_name) if where_name else ""
2300
+ if where_first_name:
2301
+ query["where_first_name"] = where_first_name
2302
+ filter_string += " first name -> '{}'".format(where_first_name) if where_first_name else ""
2303
+ if where_last_name:
2304
+ query["where_last_name"] = where_last_name
2305
+ filter_string += " last name -> '{}'".format(where_last_name) if where_last_name else ""
2306
+ if where_business_email:
2307
+ query["where_business_email"] = where_business_email
2308
+ filter_string += " business email -> '{}'".format(where_business_email) if where_business_email else ""
2309
+ if query_string:
2310
+ query["query"] = query_string
2311
+ filter_string += " query -> '{}'".format(query_string) if where_business_email else ""
2312
+ if sort:
2313
+ query["sort"] = sort
2314
+ if limit:
2315
+ if limit > 20:
2316
+ self.logger.warning(
2317
+ "Page limit for user query cannot be larger than 20. Adjusting from %d to 20.", limit
2318
+ )
2319
+ limit = 20
2320
+ query["limit"] = limit
2321
+ if page:
2322
+ query["page"] = page
2111
2323
  encoded_query = urllib.parse.urlencode(query=query, doseq=True)
2112
2324
  request_url = self.config()["membersUrlv2"] + "?{}".format(encoded_query)
2113
2325
 
2114
2326
  request_header = self.request_form_header()
2115
2327
 
2116
2328
  self.logger.debug(
2117
- "Get user with login name -> '%s'; calling -> %s",
2118
- name,
2329
+ "Get users%s; calling -> %s",
2330
+ " with{}".format(filter_string) if filter_string else "",
2119
2331
  request_url,
2120
2332
  )
2121
2333
 
@@ -2124,105 +2336,287 @@ class OTCS:
2124
2336
  method="GET",
2125
2337
  headers=request_header,
2126
2338
  timeout=None,
2127
- failure_message="Failed to get user with login -> '{}'".format(name),
2128
- warning_message="Couldn't find user with login -> '{}'".format(name),
2339
+ failure_message="Failed to get users{}".format(" with{}".format(filter_string) if filter_string else ""),
2340
+ warning_message="Couldn't find users{}".format(" with{}".format(filter_string) if filter_string else ""),
2129
2341
  show_error=show_error,
2130
2342
  )
2131
2343
 
2132
2344
  # end method definition
2133
2345
 
2134
- def add_user(
2346
+ def get_users_iterator(
2135
2347
  self,
2136
- name: str,
2137
- password: str,
2138
- first_name: str,
2139
- last_name: str,
2140
- email: str,
2141
- title: str,
2142
- base_group: int,
2143
- privileges: list | None = None,
2144
- user_type: int = 0,
2145
- ) -> dict | None:
2146
- """Add Content Server user.
2147
-
2148
- Args:
2149
- name (str):
2150
- The login name of the user.
2151
- password (str):
2152
- The password of the user.
2153
- first_name (str):
2154
- The first name of the user.
2155
- last_name (str):
2156
- The last name of the user.
2157
- email (str):
2158
- The email address of the user.
2159
- title (str):
2160
- The title of the user.
2161
- base_group (int):
2162
- The base group id of the user (e.g. department)
2163
- privileges (list, optional):
2164
- Possible values are Login, Public Access, Content Manager,
2165
- Modify Users, Modify Groups, User Admin Rights,
2166
- Grant Discovery, System Admin Rights
2167
- user_type (int, optional):
2168
- The ID of the user type. 0 = regular user, 17 = service user.
2169
-
2170
- Returns:
2171
- dict | None:
2172
- User information or None if the user couldn't be created
2173
- (e.g. because it exisits already).
2348
+ where_type: int = 0,
2349
+ where_name: str | None = None,
2350
+ where_first_name: str | None = None,
2351
+ where_last_name: str | None = None,
2352
+ where_business_email: str | None = None,
2353
+ query_string: str | None = None,
2354
+ sort: str | None = None,
2355
+ limit: int = 20,
2356
+ ) -> iter:
2357
+ """Get an iterator object that can be used to traverse OTCS users.
2174
2358
 
2175
- """
2359
+ Filters can be applied that are given by the "where" and "query" parameters.
2176
2360
 
2177
- if privileges is None:
2178
- privileges = ["Login", "Public Access"]
2361
+ Using a generator avoids loading a large users into memory at once.
2362
+ Instead you can iterate over the potential large list of users.
2179
2363
 
2180
- user_post_body = {
2181
- "type": user_type,
2182
- "name": name,
2183
- "password": password,
2184
- "first_name": first_name,
2185
- "last_name": last_name,
2186
- "business_email": email,
2187
- "title": title,
2188
- "group_id": base_group,
2189
- "privilege_login": ("Login" in privileges),
2190
- "privilege_public_access": ("Public Access" in privileges),
2191
- "privilege_content_manager": ("Content Manager" in privileges),
2192
- "privilege_modify_users": ("Modify Users" in privileges),
2193
- "privilege_modify_groups": ("Modify Groups" in privileges),
2194
- "privilege_user_admin_rights": ("User Admin Rights" in privileges),
2195
- "privilege_grant_discovery": ("Grant Discovery" in privileges),
2196
- "privilege_system_admin_rights": ("System Admin Rights" in privileges),
2197
- }
2364
+ Example usage:
2365
+ ```python
2366
+ users = otcs_object.get_users_iterator(where_type=0, limit=10)
2367
+ for user in users:
2368
+ logger.info(
2369
+ "Traversing user -> '%s' (%s)",
2370
+ otcs_object.get_result_value(response=user, key="name"),
2371
+ otcs_object.get_result_value(response=user, key="id"),
2372
+ )
2373
+ ```
2198
2374
 
2199
- request_url = self.config()["membersUrlv2"]
2200
- request_header = self.request_form_header()
2375
+ Args:
2376
+ where_type (int, optional):
2377
+ Type ID of user:
2378
+ 0 - Regular User
2379
+ 17 - Service User
2380
+ Defaults to 0 -> (Regular User)
2381
+ where_name (str | None = None):
2382
+ Name of the user (login).
2383
+ where_first_name (str | None = None):
2384
+ First name of the user.
2385
+ where_last_name (str | None = None):
2386
+ Last name of the user.
2387
+ where_business_email (str | None = None):
2388
+ Business email address of the user.
2389
+ query_string (str | None = None):
2390
+ Filters the results, returning the users with the specified query string
2391
+ in any of the following fields: log-in name, first name, last name, email address,
2392
+ and groups with the specified query string in the group name.
2393
+ NOTE: query cannot be used together with any combination of: where_name,
2394
+ where_first_name, where_last_name, where_business_email.
2395
+ The query value will be used to perform a search within the log-in name,
2396
+ first name, last name and email address properties for users and group name
2397
+ for groups to see if that value is contained within any of those properties.
2398
+ This differs from the user search that is performed in Classic UI where it
2399
+ searches for a specific property that begins with the value provided by the user.
2400
+ sort (str | None = None):
2401
+ Order by named column (Using prefixes such as sort=asc_name or sort=desc_name).
2402
+ Format can be sort = id, sort = name, sort = first_name, sort = last_name,
2403
+ sort = group_id, sort = mailaddress. If the prefix of asc or desc is not used
2404
+ then asc will be assumed.
2405
+ Default is None.
2406
+ limit (int, optional):
2407
+ The maximum number of results per page (internal default is 10). OTCS does
2408
+ not allow values > 20 so this method adjusts values > 20 to 20.
2201
2409
 
2202
- self.logger.debug("Add user -> '%s'; calling -> %s", name, request_url)
2410
+ Returns:
2411
+ iter:
2412
+ A generator yielding one user per iteration.
2413
+ If the REST API fails, returns no value.
2203
2414
 
2204
- # Clear user cache
2205
- self.get_user.cache_clear()
2415
+ """
2206
2416
 
2207
- return self.do_request(
2208
- url=request_url,
2209
- method="POST",
2210
- headers=request_header,
2211
- data=user_post_body,
2212
- timeout=None,
2213
- failure_message="Failed to add user -> '{}'".format(name),
2417
+ # First we probe how many members we have:
2418
+ response = self.get_users(
2419
+ where_type=where_type,
2420
+ where_name=where_name,
2421
+ where_first_name=where_first_name,
2422
+ where_last_name=where_last_name,
2423
+ where_business_email=where_business_email,
2424
+ query_string=query_string,
2425
+ limit=1,
2426
+ page=1,
2214
2427
  )
2428
+ if not response or "results" not in response:
2429
+ # Don't return None! Plain return is what we need for iterators.
2430
+ # Natural Termination: If the generator does not yield, it behaves
2431
+ # like an empty iterable when used in a loop or converted to a list:
2432
+ return
2215
2433
 
2216
- # end method definition
2434
+ number_of_users = response["collection"]["paging"]["total_count"]
2435
+ if not number_of_users:
2436
+ self.logger.warning(
2437
+ "No users found! Cannot iterate over users.",
2438
+ )
2439
+ # Don't return None! Plain return is what we need for iterators.
2440
+ # Natural Termination: If the generator does not yield, it behaves
2441
+ # like an empty iterable when used in a loop or converted to a list:
2442
+ return
2217
2443
 
2218
- def search_user(self, value: str, field: str = "where_name") -> dict | None:
2444
+ # If the group has many members we need to go through all pages
2445
+ # Adding page_size - 1 ensures that any remainder from the division is
2446
+ # accounted for, effectively rounding up. Integer division (//) performs floor division,
2447
+ # giving the desired number of pages:
2448
+ total_pages = (number_of_users + limit - 1) // limit
2449
+
2450
+ for page in range(1, total_pages + 1):
2451
+ # Get the next page of sub node items:
2452
+ response = self.get_users(
2453
+ where_type=where_type,
2454
+ where_name=where_name,
2455
+ where_first_name=where_first_name,
2456
+ where_last_name=where_last_name,
2457
+ where_business_email=where_business_email,
2458
+ query_string=query_string,
2459
+ sort=sort,
2460
+ limit=limit,
2461
+ page=page,
2462
+ )
2463
+ if not response or not response.get("results", None):
2464
+ self.logger.warning(
2465
+ "Failed to retrieve users (page -> %d)",
2466
+ page,
2467
+ )
2468
+ return
2469
+
2470
+ # Yield nodes one at a time:
2471
+ yield from response["results"]
2472
+
2473
+ # end for page in range(1, total_pages + 1)
2474
+
2475
+ # end method definition
2476
+
2477
+ @cache
2478
+ def get_user(self, name: str, user_type: int = 0, show_error: bool = False) -> dict | None:
2479
+ """Get a Content Server user based on the login name and type.
2480
+
2481
+ Args:
2482
+ name (str):
2483
+ Name of the user (login).
2484
+ user_type (int, optional):
2485
+ Type ID of user:
2486
+ 0 - Regular User
2487
+ 17 - Service User
2488
+ Defaults to 0 -> (Regular User)
2489
+
2490
+ show_error (bool, optional):
2491
+ If True, treat as an error if the user is not found. Defaults to False.
2492
+
2493
+ Returns:
2494
+ dict | None:
2495
+ User information as a dictionary, or None if the user could not be found
2496
+ (e.g., because it doesn't exist).
2497
+
2498
+ Example:
2499
+ ```json
2500
+ {
2501
+ 'collection': {
2502
+ 'paging': {
2503
+ 'limit': 10,
2504
+ 'page': 1,
2505
+ 'page_total': 1,
2506
+ 'range_max': 1,
2507
+ 'range_min': 1,
2508
+ 'total_count': 1
2509
+ },
2510
+ 'sorting': {
2511
+ 'sort': [
2512
+ {
2513
+ 'key': 'sort',
2514
+ 'value': 'asc_id'
2515
+ }
2516
+ ]
2517
+ }
2518
+ },
2519
+ 'links': {
2520
+ 'data': {
2521
+ 'self': {
2522
+ 'body': '',
2523
+ 'content_type': '',
2524
+ 'href': '/api/v2/members?where_first_name=Peter',
2525
+ 'method': 'GET',
2526
+ 'name': ''
2527
+ }
2528
+ }
2529
+ },
2530
+ 'results': [
2531
+ {
2532
+ 'data': {
2533
+ 'properties': {
2534
+ 'birth_date': None,
2535
+ 'business_email': 'pramos@M365x61936377.onmicrosoft.com',
2536
+ 'business_fax': None,
2537
+ 'business_phone': None,
2538
+ 'cell_phone': None,
2539
+ 'deleted': False,
2540
+ 'display_language': None,
2541
+ 'first_name': 'Peter',
2542
+ 'gender': None,
2543
+ 'group_id': 8006,
2544
+ 'home_address_1': None,
2545
+ 'home_address_2': None,
2546
+ 'home_fax': None,
2547
+ 'home_phone': None,
2548
+ 'id': 8123,
2549
+ 'initials': None,
2550
+ 'last_name': 'Ramos',
2551
+ 'middle_name': None,
2552
+ 'name': 'pramos',
2553
+ 'name_formatted': 'Peter Ramos',
2554
+ 'office_location': None,
2555
+ 'pager': None,
2556
+ 'personal_email': None,
2557
+ 'photo_id': 13981,
2558
+ 'photo_url': 'api/v1/members/8123/photo?v=13981.1',
2559
+ 'privilege_content_manager': False,
2560
+ 'privilege_grant_discovery': False,
2561
+ 'privilege_login': True,
2562
+ 'privilege_modify_groups': False,
2563
+ 'privilege_modify_users': False,
2564
+ 'privilege_public_access': True,
2565
+ 'privilege_system_admin_rights': False,
2566
+ 'privilege_user_admin_rights': False,
2567
+ 'time_zone': -1,
2568
+ 'title': 'Maintenance Planner',
2569
+ 'type': 0,
2570
+ 'type_name': 'User'
2571
+ }
2572
+ }
2573
+ }
2574
+ ]
2575
+ }
2576
+ ```
2577
+
2578
+ To access the (login) name of the first user found, use
2579
+ `["results"][0]["data"]["properties"]["name"]`.
2580
+ Alternatively, use the method `get_result_value(response, "name", 0)`.
2581
+
2582
+ """
2583
+
2584
+ # Add query parameters (embedded in the URL)
2585
+ # Using type = 0 for OTCS groups or type = 17 for service user:
2586
+ query = {"where_type": user_type, "where_name": name}
2587
+ encoded_query = urllib.parse.urlencode(query=query, doseq=True)
2588
+ request_url = self.config()["membersUrlv2"] + "?{}".format(encoded_query)
2589
+
2590
+ request_header = self.request_form_header()
2591
+
2592
+ self.logger.debug(
2593
+ "Get user with login name -> '%s'%s; calling -> %s",
2594
+ name,
2595
+ ", type -> 'service user'" if user_type == 17 else "",
2596
+ request_url,
2597
+ )
2598
+
2599
+ return self.do_request(
2600
+ url=request_url,
2601
+ method="GET",
2602
+ headers=request_header,
2603
+ timeout=None,
2604
+ failure_message="Failed to get user with login -> '{}' and type -> {}".format(name, user_type),
2605
+ warning_message="Couldn't find user with login -> '{}' and type -> {}".format(name, user_type),
2606
+ show_error=show_error,
2607
+ )
2608
+
2609
+ # end method definition
2610
+
2611
+ def search_user(self, value: str, field: str = "where_name") -> dict | None:
2219
2612
  """Find a user based on search criteria.
2220
2613
 
2221
2614
  Args:
2222
2615
  value (str):
2223
2616
  Field value to search for.
2224
2617
  field (str):
2225
- User field to search with (e.g. "where_name", "where_first_name", "where_last_name").
2618
+ User field to search with (e.g. "where_type", "where_name",
2619
+ "where_first_name", "where_last_name", "where_business_email", "query").
2226
2620
 
2227
2621
  Returns:
2228
2622
  dict | None:
@@ -2233,11 +2627,34 @@ class OTCS:
2233
2627
  ```json
2234
2628
  {
2235
2629
  'collection': {
2236
- 'paging': {...},
2237
- 'sorting': {...}
2630
+ 'paging': {
2631
+ 'limit': 10,
2632
+ 'links': {'data': {...}},
2633
+ 'page': 1,
2634
+ 'page_total': 2,
2635
+ 'range_max': 10,
2636
+ 'range_min': 1,
2637
+ 'total_count': 11
2638
+ },
2639
+ 'sorting': {
2640
+ 'sort': [
2641
+ {
2642
+ 'key': 'sort',
2643
+ 'value': 'asc_id'
2644
+ }
2645
+ ]
2646
+ }
2238
2647
  },
2239
2648
  'links': {
2240
- 'data': {...}
2649
+ 'data': {
2650
+ 'self': {
2651
+ 'body': '',
2652
+ 'content_type': '',
2653
+ 'href': '/api/v2/members?where_first_name=Peter',
2654
+ 'method': 'GET',
2655
+ 'name': ''
2656
+ }
2657
+ }
2241
2658
  },
2242
2659
  'results': [
2243
2660
  {
@@ -2263,7 +2680,23 @@ class OTCS:
2263
2680
  'middle_name': None,
2264
2681
  'name': 'dfoxhoven',
2265
2682
  'name_formatted': 'Deke Foxhoven',
2266
- ...
2683
+ 'office_location': None,
2684
+ 'pager': None,
2685
+ 'personal_email': None,
2686
+ 'photo_id': 17467,
2687
+ 'photo_url': 'api/v1/members/8123/photo?v=17467.1',
2688
+ 'privilege_content_manager': False,
2689
+ 'privilege_grant_discovery': False,
2690
+ 'privilege_login': True,
2691
+ 'privilege_modify_groups': False,
2692
+ 'privilege_modify_users': False,
2693
+ 'privilege_public_access': True,
2694
+ 'privilege_system_admin_rights': False,
2695
+ 'privilege_user_admin_rights': False,
2696
+ 'time_zone': -1,
2697
+ 'title': 'Contract Manager',
2698
+ 'type': 0,
2699
+ 'type_name': 'User'
2267
2700
  }
2268
2701
  }
2269
2702
  }
@@ -2296,6 +2729,90 @@ class OTCS:
2296
2729
 
2297
2730
  # end method definition
2298
2731
 
2732
+ def add_user(
2733
+ self,
2734
+ name: str,
2735
+ password: str,
2736
+ first_name: str,
2737
+ last_name: str,
2738
+ email: str,
2739
+ title: str,
2740
+ base_group: int,
2741
+ privileges: list | None = None,
2742
+ user_type: int = 0,
2743
+ ) -> dict | None:
2744
+ """Add Content Server user.
2745
+
2746
+ Args:
2747
+ name (str):
2748
+ The login name of the user.
2749
+ password (str):
2750
+ The password of the user.
2751
+ first_name (str):
2752
+ The first name of the user.
2753
+ last_name (str):
2754
+ The last name of the user.
2755
+ email (str):
2756
+ The email address of the user.
2757
+ title (str):
2758
+ The title of the user.
2759
+ base_group (int):
2760
+ The base group id of the user (e.g. department)
2761
+ privileges (list, optional):
2762
+ Possible values are Login, Public Access, Content Manager,
2763
+ Modify Users, Modify Groups, User Admin Rights,
2764
+ Grant Discovery, System Admin Rights
2765
+ user_type (int, optional):
2766
+ The ID of the user type. 0 = regular user, 17 = service user.
2767
+
2768
+ Returns:
2769
+ dict | None:
2770
+ User information or None if the user couldn't be created
2771
+ (e.g. because it exisits already).
2772
+
2773
+ """
2774
+
2775
+ if privileges is None:
2776
+ privileges = ["Login", "Public Access"]
2777
+
2778
+ user_post_body = {
2779
+ "type": user_type,
2780
+ "name": name,
2781
+ "password": password,
2782
+ "first_name": first_name,
2783
+ "last_name": last_name,
2784
+ "business_email": email,
2785
+ "title": title,
2786
+ "group_id": base_group,
2787
+ "privilege_login": ("Login" in privileges),
2788
+ "privilege_public_access": ("Public Access" in privileges),
2789
+ "privilege_content_manager": ("Content Manager" in privileges),
2790
+ "privilege_modify_users": ("Modify Users" in privileges),
2791
+ "privilege_modify_groups": ("Modify Groups" in privileges),
2792
+ "privilege_user_admin_rights": ("User Admin Rights" in privileges),
2793
+ "privilege_grant_discovery": ("Grant Discovery" in privileges),
2794
+ "privilege_system_admin_rights": ("System Admin Rights" in privileges),
2795
+ }
2796
+
2797
+ request_url = self.config()["membersUrlv2"]
2798
+ request_header = self.request_form_header()
2799
+
2800
+ self.logger.debug("Add user -> '%s'; calling -> %s", name, request_url)
2801
+
2802
+ # Clear user cache
2803
+ self.get_user.cache_clear()
2804
+
2805
+ return self.do_request(
2806
+ url=request_url,
2807
+ method="POST",
2808
+ headers=request_header,
2809
+ data=user_post_body,
2810
+ timeout=None,
2811
+ failure_message="Failed to add user -> '{}'".format(name),
2812
+ )
2813
+
2814
+ # end method definition
2815
+
2299
2816
  def update_user(self, user_id: int, field: str, value: str) -> dict | None:
2300
2817
  """Update a defined field for a user.
2301
2818
 
@@ -2657,64 +3174,286 @@ class OTCS:
2657
3174
 
2658
3175
  """
2659
3176
 
2660
- request_url = self.config()["favoritesUrl"] + "/" + str(node_id)
3177
+ request_url = self.config()["favoritesUrl"] + "/" + str(node_id)
3178
+ request_header = self.request_form_header()
3179
+
3180
+ self.logger.debug(
3181
+ "Adding favorite for node ID -> %s; calling -> %s",
3182
+ node_id,
3183
+ request_url,
3184
+ )
3185
+
3186
+ return self.do_request(
3187
+ url=request_url,
3188
+ method="POST",
3189
+ headers=request_header,
3190
+ timeout=None,
3191
+ failure_message="Failed to add favorite for node ID -> {}".format(node_id),
3192
+ )
3193
+
3194
+ # end method definition
3195
+
3196
+ def add_favorite_tab(self, tab_name: str, order: int) -> dict | None:
3197
+ """Add a favorite tab for the current (authenticated) user.
3198
+
3199
+ Args:
3200
+ tab_name (str):
3201
+ The name of the new tab.
3202
+ order (int):
3203
+ The ordering position of the new tab.
3204
+
3205
+ Returns:
3206
+ dict | None:
3207
+ Request response or None if the favorite tab creation request has failed.
3208
+
3209
+ """
3210
+
3211
+ favorite_tab_post_body = {"name": tab_name, "order": str(order)}
3212
+
3213
+ request_url = self.config()["favoritesUrl"] + "/tabs"
3214
+ request_header = self.request_form_header()
3215
+
3216
+ self.logger.debug(
3217
+ "Adding favorite tab -> %s; calling -> %s",
3218
+ tab_name,
3219
+ request_url,
3220
+ )
3221
+
3222
+ return self.do_request(
3223
+ url=request_url,
3224
+ method="POST",
3225
+ headers=request_header,
3226
+ data=favorite_tab_post_body,
3227
+ timeout=None,
3228
+ failure_message="Failed to add favorite tab -> {}".format(tab_name),
3229
+ )
3230
+
3231
+ # end method definition
3232
+
3233
+ def get_groups(
3234
+ self,
3235
+ where_name: str | None = None,
3236
+ sort: str | None = None,
3237
+ limit: int = 20,
3238
+ page: int = 1,
3239
+ show_error: bool = False,
3240
+ ) -> dict | None:
3241
+ """Get a list of Content Server groups.
3242
+
3243
+ Args:
3244
+ where_name (str | None = None):
3245
+ The name of the group to look up.
3246
+ sort (str | None = None):
3247
+ Order by named column (Using prefixes such as sort=asc_name or sort=desc_name).
3248
+ Format can be sort = id, sort = name, sort = group_id.
3249
+ If the prefix of asc or desc is not used then asc will be assumed.
3250
+ Default is None.
3251
+ limit (int, optional):
3252
+ The maximum number of results per page (internal default is 10). OTCS does
3253
+ not allow values > 20 so this method adjusts values > 20 to 20.
3254
+ page (int, optional):
3255
+ The page number to retrieve.
3256
+ show_error (bool, optional):
3257
+ If True, treats the absence of the group as an error. Defaults to False.
3258
+
3259
+ Returns:
3260
+ dict | None:
3261
+ Group information as a dictionary, or None if the group is not found.
3262
+
3263
+ Example:
3264
+ ```json
3265
+ {
3266
+ 'collection': {
3267
+ 'paging': {
3268
+ 'limit': 10,
3269
+ 'page': 1,
3270
+ 'page_total': 1,
3271
+ 'range_max': 1,
3272
+ 'range_min': 1,
3273
+ 'total_count': 1
3274
+ },
3275
+ 'sorting': {
3276
+ 'sort': [
3277
+ {
3278
+ 'key': 'sort',
3279
+ 'value': 'asc_id'
3280
+ }
3281
+ ]
3282
+ }
3283
+ },
3284
+ 'links': {
3285
+ 'data': {
3286
+ 'self': {
3287
+ 'body': '',
3288
+ 'content_type': '',
3289
+ 'href': '/api/v2/members?where_name=Procurement&where_type=1',
3290
+ 'method': 'GET',
3291
+ 'name': ''
3292
+ }
3293
+ }
3294
+ },
3295
+ 'results': [
3296
+ {
3297
+ 'data': {
3298
+ 'properties': {
3299
+ 'deleted': False,
3300
+ 'id': 17649,
3301
+ 'initials': 'P',
3302
+ 'leader_id': None,
3303
+ 'name': 'Procurement',
3304
+ 'name_formatted': 'Procurement',
3305
+ 'type': 1,
3306
+ 'type_name': 'Group'
3307
+ }
3308
+ }
3309
+ }
3310
+ ]
3311
+ }
3312
+ ```
3313
+
3314
+ To access the ID of the first group found, use ["results"][0]["data"]["properties"]["id"].
3315
+ Or use the method get_result_value(response, key="id")
3316
+
3317
+ """
3318
+
3319
+ # Add query parameters (embedded in the URL)
3320
+ # Using type = 1 for OTCS groups:
3321
+ query = {"where_type": 1}
3322
+ if where_name:
3323
+ query["where_name"] = where_name
3324
+ if sort:
3325
+ query["sort"] = sort
3326
+ if limit:
3327
+ if limit > 20:
3328
+ self.logger.warning(
3329
+ "Page limit for group query cannot be larger than 20. Adjusting from %d to 20.", limit
3330
+ )
3331
+ limit = 20
3332
+ query["limit"] = limit
3333
+ if page:
3334
+ query["page"] = page
3335
+ encoded_query = urllib.parse.urlencode(query=query, doseq=True)
3336
+ request_url = self.config()["membersUrlv2"] + "?{}".format(encoded_query)
3337
+
2661
3338
  request_header = self.request_form_header()
2662
3339
 
2663
3340
  self.logger.debug(
2664
- "Adding favorite for node ID -> %s; calling -> %s",
2665
- node_id,
3341
+ "Get groups%s; calling -> %s",
3342
+ " with name -> '{}'".format(where_name) if where_name else "",
2666
3343
  request_url,
2667
3344
  )
2668
3345
 
2669
3346
  return self.do_request(
2670
3347
  url=request_url,
2671
- method="POST",
3348
+ method="GET",
2672
3349
  headers=request_header,
2673
3350
  timeout=None,
2674
- failure_message="Failed to add favorite for node ID -> {}".format(node_id),
3351
+ failure_message="Failed to get groups{}".format(
3352
+ " with name -> '{}'".format(where_name) if where_name else ""
3353
+ ),
3354
+ warning_message="Groups{} do not yet exist!".format(
3355
+ " with name -> '{}'".format(where_name) if where_name else ""
3356
+ ),
3357
+ show_error=show_error,
2675
3358
  )
2676
3359
 
2677
3360
  # end method definition
2678
3361
 
2679
- def add_favorite_tab(self, tab_name: str, order: int) -> dict | None:
2680
- """Add a favorite tab for the current (authenticated) user.
3362
+ def get_groups_iterator(
3363
+ self,
3364
+ where_name: str | None = None,
3365
+ sort: str | None = None,
3366
+ limit: int = 20,
3367
+ ) -> iter:
3368
+ """Get an iterator object that can be used to traverse OTCS groups.
3369
+
3370
+ Filters can be applied that are given by the "where" and "query" parameters.
3371
+
3372
+ Using a generator avoids loading a large number of groups into memory at once.
3373
+ Instead you can iterate over the potential large list of groups.
3374
+
3375
+ Example usage:
3376
+ ```python
3377
+ groups = otcs_object.get_groups_iterator(limit=10)
3378
+ for group in groups:
3379
+ logger.info(
3380
+ "Traversing group -> '%s' (%s)",
3381
+ otcs_object.get_result_value(response=group, key="name"),
3382
+ otcs_object.get_result_value(response=group, key="id"),
3383
+ )
3384
+ ```
2681
3385
 
2682
3386
  Args:
2683
- tab_name (str):
2684
- The name of the new tab.
2685
- order (int):
2686
- The ordering position of the new tab.
3387
+ where_name (str | None = None):
3388
+ Name of the user (login).
3389
+ sort (str | None = None):
3390
+ Order by named column (Using prefixes such as sort=asc_name or sort=desc_name ).
3391
+ Format can be sort = id, sort = name, sort = group_id.
3392
+ If the prefix of asc or desc is not used then asc will be assumed.
3393
+ Default is None.
3394
+ limit (int, optional):
3395
+ The maximum number of results per page (internal default is 10). OTCS does
3396
+ not allow values > 20 so this method adjusts values > 20 to 20.
2687
3397
 
2688
3398
  Returns:
2689
- dict | None:
2690
- Request response or None if the favorite tab creation request has failed.
3399
+ iter:
3400
+ A generator yielding one group per iteration.
3401
+ If the REST API fails, returns no value.
2691
3402
 
2692
3403
  """
2693
3404
 
2694
- favorite_tab_post_body = {"name": tab_name, "order": str(order)}
3405
+ # First we probe how many members we have:
3406
+ response = self.get_groups(
3407
+ where_name=where_name,
3408
+ limit=1,
3409
+ page=1,
3410
+ )
3411
+ if not response or "results" not in response:
3412
+ # Don't return None! Plain return is what we need for iterators.
3413
+ # Natural Termination: If the generator does not yield, it behaves
3414
+ # like an empty iterable when used in a loop or converted to a list:
3415
+ return
2695
3416
 
2696
- request_url = self.config()["favoritesUrl"] + "/tabs"
2697
- request_header = self.request_form_header()
3417
+ number_of_users = response["collection"]["paging"]["total_count"]
3418
+ if not number_of_users:
3419
+ self.logger.warning(
3420
+ "No groups found! Cannot iterate over groups.",
3421
+ )
3422
+ # Don't return None! Plain return is what we need for iterators.
3423
+ # Natural Termination: If the generator does not yield, it behaves
3424
+ # like an empty iterable when used in a loop or converted to a list:
3425
+ return
2698
3426
 
2699
- self.logger.debug(
2700
- "Adding favorite tab -> %s; calling -> %s",
2701
- tab_name,
2702
- request_url,
2703
- )
3427
+ # If the group has many members we need to go through all pages
3428
+ # Adding page_size - 1 ensures that any remainder from the division is
3429
+ # accounted for, effectively rounding up. Integer division (//) performs floor division,
3430
+ # giving the desired number of pages:
3431
+ total_pages = (number_of_users + limit - 1) // limit
2704
3432
 
2705
- return self.do_request(
2706
- url=request_url,
2707
- method="POST",
2708
- headers=request_header,
2709
- data=favorite_tab_post_body,
2710
- timeout=None,
2711
- failure_message="Failed to add favorite tab -> {}".format(tab_name),
2712
- )
3433
+ for page in range(1, total_pages + 1):
3434
+ # Get the next page of sub node items:
3435
+ response = self.get_groups(
3436
+ where_name=where_name,
3437
+ sort=sort,
3438
+ limit=limit,
3439
+ page=page,
3440
+ )
3441
+ if not response or not response.get("results", None):
3442
+ self.logger.warning(
3443
+ "Failed to retrieve groups (page -> %d)",
3444
+ page,
3445
+ )
3446
+ return
3447
+
3448
+ # Yield nodes one at a time:
3449
+ yield from response["results"]
3450
+
3451
+ # end for page in range(1, total_pages + 1)
2713
3452
 
2714
3453
  # end method definition
2715
3454
 
2716
3455
  def get_group(self, name: str, show_error: bool = False) -> dict | None:
2717
- """Look up a Content Server group.
3456
+ """Get the Content Server group with a given name.
2718
3457
 
2719
3458
  Args:
2720
3459
  name (str):
@@ -2725,23 +3464,65 @@ class OTCS:
2725
3464
  Returns:
2726
3465
  dict | None:
2727
3466
  Group information as a dictionary, or None if the group is not found.
2728
- The returned information has the following structure:
3467
+
3468
+ Example:
3469
+ ```json
2729
3470
  {
2730
- "data": [
3471
+ 'collection': {
3472
+ 'paging': {
3473
+ 'limit': 10,
3474
+ 'page': 1,
3475
+ 'page_total': 1,
3476
+ 'range_max': 1,
3477
+ 'range_min': 1,
3478
+ 'total_count': 1
3479
+ },
3480
+ 'sorting': {
3481
+ 'sort': [
3482
+ {
3483
+ 'key': 'sort',
3484
+ 'value': 'asc_id'
3485
+ }
3486
+ ]
3487
+ }
3488
+ },
3489
+ 'links': {
3490
+ 'data': {
3491
+ 'self': {
3492
+ 'body': '',
3493
+ 'content_type': '',
3494
+ 'href': '/api/v2/members?where_name=Procurement&where_type=1',
3495
+ 'method': 'GET',
3496
+ 'name': ''
3497
+ }
3498
+ }
3499
+ },
3500
+ 'results': [
2731
3501
  {
2732
- "id": 0,
2733
- "name": "string",
2734
- ...
3502
+ 'data': {
3503
+ 'properties': {
3504
+ 'deleted': False,
3505
+ 'id': 17649,
3506
+ 'initials': 'P',
3507
+ 'leader_id': None,
3508
+ 'name': 'Procurement',
3509
+ 'name_formatted': 'Procurement',
3510
+ 'type': 1,
3511
+ 'type_name': 'Group'
3512
+ }
3513
+ }
2735
3514
  }
2736
3515
  ]
2737
3516
  }
3517
+ ```
2738
3518
 
2739
- To access the ID of the first group found, use ["data"][0]["id"].
3519
+ To access the ID of the first group found, use ["results"][0]["data"]["properties"]["id"].
3520
+ Or use the method get_result_value(response, key="id")
2740
3521
 
2741
3522
  """
2742
3523
 
2743
- # Add query parameters (these are NOT passed via JSon body!)
2744
- # type = 1 ==> Group
3524
+ # Add query parameters (embedded in the URL)
3525
+ # Using type = 1 for OTCS groups:
2745
3526
  query = {"where_type": 1, "where_name": name}
2746
3527
  encoded_query = urllib.parse.urlencode(query=query, doseq=True)
2747
3528
  request_url = self.config()["membersUrlv2"] + "?{}".format(encoded_query)
@@ -2839,10 +3620,6 @@ class OTCS:
2839
3620
 
2840
3621
  query = {}
2841
3622
  query["where_type"] = str(member_type)
2842
- if limit:
2843
- query["limit"] = limit
2844
- if page:
2845
- query["page"] = page
2846
3623
  if where_name:
2847
3624
  query["where_name"] = where_name
2848
3625
  if where_first_name:
@@ -2851,12 +3628,13 @@ class OTCS:
2851
3628
  query["where_last_name"] = where_last_name
2852
3629
  if where_business_email:
2853
3630
  query["where_business_email"] = where_business_email
2854
-
3631
+ if limit:
3632
+ query["limit"] = limit
3633
+ if page:
3634
+ query["page"] = page
2855
3635
  encoded_query = urllib.parse.urlencode(query=query, doseq=True)
2856
-
2857
- # default limit is 25 which may not be enough for groups with many members
2858
- # where_type = 1 makes sure we just get groups and not users
2859
3636
  request_url = self.config()["membersUrlv2"] + "/" + str(group) + "/members?{}".format(encoded_query)
3637
+
2860
3638
  request_header = self.request_form_header()
2861
3639
 
2862
3640
  self.logger.debug(
@@ -2891,8 +3669,8 @@ class OTCS:
2891
3669
 
2892
3670
  Filters can be applied that are given by the "where" parameters.
2893
3671
 
2894
- Using a generator avoids loading a large number of nodes into memory at once.
2895
- Instead you can iterate over the potential large list of related workspaces.
3672
+ Using a generator avoids loading a large number of group members into memory at once.
3673
+ Instead you can iterate over the potential large list of group members.
2896
3674
 
2897
3675
  Example usage:
2898
3676
  ```python
@@ -6106,6 +6884,158 @@ class OTCS:
6106
6884
 
6107
6885
  # end method definition
6108
6886
 
6887
+ def get_document_versions(self, node_id: str) -> list | None:
6888
+ """Get a list of the document versions of a document node.
6889
+
6890
+ Args:
6891
+ node_id (str):
6892
+ Node ID of the document.
6893
+
6894
+ Returns:
6895
+ list | None:
6896
+ The list of document versions.
6897
+
6898
+ Example:
6899
+ {
6900
+ 'links': {'data': {...}},
6901
+ 'results': [
6902
+ {
6903
+ 'data': {
6904
+ 'versions': {
6905
+ 'create_date': '2025-06-07T05:29:22Z',
6906
+ 'description': '',
6907
+ 'external_create_date': None,
6908
+ 'external_identity': '',
6909
+ 'external_identity_type': '',
6910
+ 'external_modify_date': '2025-06-05T10:06:02',
6911
+ 'external_source': 'file_system',
6912
+ 'file_create_date': '2025-06-07T05:29:22Z',
6913
+ 'file_modify_date': '2025-06-05T10:06:02Z',
6914
+ 'file_name': 'OpenText-PPT-Presentation-FY25-LIGHT-FINAL.pptx',
6915
+ 'file_size': 4057237,
6916
+ 'file_type': 'pptx',
6917
+ 'has_generation': False,
6918
+ 'id': 107044,
6919
+ 'locked': False,
6920
+ 'locked_date': None,
6921
+ 'locked_user_id': None,
6922
+ 'mime_type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
6923
+ 'modify_date': '2025-06-07T05:29:22Z',
6924
+ 'name': 'OpenText-PPT-Presentation-FY25-LIGHT-FINAL.pptx',
6925
+ 'owner_id': 1000,
6926
+ 'provider_id': 103563,
6927
+ 'version_id': 103564,
6928
+ 'version_number': 2,
6929
+ 'version_number_major': 0,
6930
+ 'version_number_minor': 2,
6931
+ 'version_number_name': '2'
6932
+ }
6933
+ }
6934
+ }
6935
+ ]
6936
+ }
6937
+
6938
+ """
6939
+
6940
+ request_url = self.config()["nodesUrlv2"] + "/" + str(node_id) + "/versions"
6941
+ request_header = self.request_form_header()
6942
+
6943
+ self.logger.debug(
6944
+ "Get a list of all versions of document with node ID -> %s; calling -> %s",
6945
+ str(node_id),
6946
+ request_url,
6947
+ )
6948
+
6949
+ return self.do_request(
6950
+ url=request_url,
6951
+ method="GET",
6952
+ headers=request_header,
6953
+ timeout=None,
6954
+ failure_message="Failed to get list of versions of document with node ID -> {}".format(
6955
+ str(node_id),
6956
+ ),
6957
+ )
6958
+
6959
+ # end method definition
6960
+
6961
+ def get_document_version(self, node_id: str, version_number: int) -> dict | None:
6962
+ """Get a particular version of a document based on the version number.
6963
+
6964
+ The first version (oldest) typically has the number 1.
6965
+
6966
+ Args:
6967
+ node_id (str):
6968
+ Node ID of the document.
6969
+ version_number (int):
6970
+ The version number.
6971
+
6972
+ Returns:
6973
+ dict | None:
6974
+ The version data.
6975
+
6976
+ Example:
6977
+ {
6978
+ 'links': {'data': {...}},
6979
+ 'results': {
6980
+ 'data': {
6981
+ 'versions': {
6982
+ 'create_date': '2025-06-07T05:29:22Z',
6983
+ 'description': '',
6984
+ 'external_create_date': None,
6985
+ 'external_identity': '',
6986
+ 'external_identity_type': '',
6987
+ 'external_modify_date': '2025-06-05T10:06:02',
6988
+ 'external_source': 'file_system',
6989
+ 'file_create_date': '2025-06-07T05:29:22Z',
6990
+ 'file_modify_date': '2025-06-05T10:06:02Z',
6991
+ 'file_name': 'OpenText-PPT-Presentation-FY25-LIGHT-FINAL.pptx',
6992
+ 'file_size': 4057237,
6993
+ 'file_type': 'pptx',
6994
+ 'has_generation': False,
6995
+ 'id': 107044,
6996
+ 'locked': False,
6997
+ 'locked_date': None,
6998
+ 'locked_user_id': None,
6999
+ 'mime_type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
7000
+ 'modify_date': '2025-06-07T05:29:22Z',
7001
+ 'name': 'OpenText-PPT-Presentation-FY25-LIGHT-FINAL.pptx',
7002
+ 'owner_id': 1000,
7003
+ 'provider_id': 103563,
7004
+ 'version_id': 103564,
7005
+ 'version_number': 2,
7006
+ 'version_number_major': 0,
7007
+ 'version_number_minor': 2,
7008
+ 'version_number_name': '2'
7009
+ }
7010
+ }
7011
+ }
7012
+ }
7013
+
7014
+ """
7015
+
7016
+ request_url = self.config()["nodesUrlv2"] + "/" + str(node_id) + "/versions/" + str(version_number)
7017
+ request_header = self.request_form_header()
7018
+
7019
+ self.logger.debug(
7020
+ "Get version -> %d of document with node ID -> %s; calling -> %s",
7021
+ version_number,
7022
+ str(node_id),
7023
+ request_url,
7024
+ )
7025
+
7026
+ return self.do_request(
7027
+ url=request_url,
7028
+ method="GET",
7029
+ headers=request_header,
7030
+ timeout=None,
7031
+ failure_message="Failed to get version -> {} of document with node ID -> {}".format(
7032
+ version_number,
7033
+ str(node_id),
7034
+ ),
7035
+ )
7036
+
7037
+ # end method definition
7038
+
6109
7039
  def get_latest_document_version(self, node_id: int) -> dict | None:
6110
7040
  """Get latest version of a document node based on the node ID.
6111
7041
 
@@ -6119,6 +7049,7 @@ class OTCS:
6119
7049
 
6120
7050
  """
6121
7051
 
7052
+ # This Method requires V1of the REST API!
6122
7053
  request_url = self.config()["nodesUrl"] + "/" + str(node_id) + "/versions/latest"
6123
7054
  request_header = self.request_form_header()
6124
7055
 
@@ -6140,6 +7071,63 @@ class OTCS:
6140
7071
 
6141
7072
  # end method definition
6142
7073
 
7074
+ def purge_document_versions(self, node_id: int, versions_to_keep: int = 1) -> dict | None:
7075
+ """Purge versions of a document based on the node ID of the document.
7076
+
7077
+ Args:
7078
+ node_id (int):
7079
+ The ID of the document node to purge versions for.
7080
+ versions_to_keep (int):
7081
+ Number of versions to keep (from the newest to the oldest).
7082
+ The minimum allowed number is 1. This is also the default.
7083
+ If 1 is provided it means to keep the nerwest version only.
7084
+
7085
+ Returns:
7086
+ dict | None:
7087
+ The result data or None if the request fails.
7088
+
7089
+ Example:
7090
+ {
7091
+ 'links': {'data': {...}},
7092
+ 'results': {}
7093
+ }
7094
+
7095
+ """
7096
+
7097
+ # Sanity check:
7098
+ if versions_to_keep < 1:
7099
+ self.logger.error("Purging to less than 1 version is not possible. The value -> %d is not valid!")
7100
+ return None
7101
+
7102
+ request_url = self.config()["nodesUrlv2"] + "/" + str(node_id) + "/versions"
7103
+ request_header = self.request_form_header()
7104
+
7105
+ purge_delete_body = {
7106
+ "number_to_keep": versions_to_keep,
7107
+ }
7108
+
7109
+ self.logger.debug(
7110
+ "Purge document versions down to the newest%s version%s of document with node ID -> %s; calling -> %s",
7111
+ " {}".format(versions_to_keep) if versions_to_keep > 1 else "",
7112
+ "s" if versions_to_keep > 1 else "",
7113
+ str(node_id),
7114
+ request_url,
7115
+ )
7116
+
7117
+ return self.do_request(
7118
+ url=request_url,
7119
+ method="DELETE",
7120
+ headers=request_header,
7121
+ data=purge_delete_body,
7122
+ timeout=None,
7123
+ failure_message="Failed to purge to {} versions of document with node ID -> {}".format(
7124
+ versions_to_keep,
7125
+ str(node_id),
7126
+ ),
7127
+ )
7128
+
7129
+ # end method definition
7130
+
6143
7131
  def get_document_content(
6144
7132
  self,
6145
7133
  node_id: int,
@@ -6190,7 +7178,7 @@ class OTCS:
6190
7178
  method="GET",
6191
7179
  headers=request_header,
6192
7180
  timeout=None,
6193
- failure_message="Failed to download document with node ID -> {}".format(
7181
+ failure_message="Failed to get content of document with node ID -> {}".format(
6194
7182
  node_id,
6195
7183
  ),
6196
7184
  parse_request_response=parse_request_response,
@@ -6214,7 +7202,7 @@ class OTCS:
6214
7202
  node_id: int,
6215
7203
  version_number: str = "",
6216
7204
  ) -> list | dict | None:
6217
- """Get document content from Extended ECM and read content as JSON.
7205
+ """Get document content from Content Server and parse content as JSON.
6218
7206
 
6219
7207
  Args:
6220
7208
  node_id (int):
@@ -6242,16 +7230,16 @@ class OTCS:
6242
7230
  self,
6243
7231
  node_id: int,
6244
7232
  file_path: str,
6245
- version_number: str = "",
7233
+ version_number: str | int = "",
6246
7234
  ) -> bool:
6247
- """Download a document from Extended ECM to local file system.
7235
+ """Download a document from OTCS to local file system.
6248
7236
 
6249
7237
  Args:
6250
7238
  node_id (int):
6251
7239
  The node ID of the document to download
6252
7240
  file_path (str):
6253
7241
  The local file path (directory).
6254
- version_number (str, optional):
7242
+ version_number (str | int, optional):
6255
7243
  The version of the document to download.
6256
7244
  If version = "" then download the latest version.
6257
7245
 
@@ -6773,7 +7761,7 @@ class OTCS:
6773
7761
  connection_name: str,
6774
7762
  show_error: bool = False,
6775
7763
  ) -> dict | None:
6776
- """Get Extended ECM external system connection (e.g. SAP, Salesforce, SuccessFactors).
7764
+ """Get external system connection (e.g. SAP, Salesforce, SuccessFactors).
6777
7765
 
6778
7766
  Args:
6779
7767
  connection_name (str):
@@ -7835,7 +8823,7 @@ class OTCS:
7835
8823
  """Get all workspace types configured in Extended ECM.
7836
8824
 
7837
8825
  This REST API is very limited. It does not return all workspace type properties
7838
- you can see in Extended ECM admin page.
8826
+ you can see in OTCS business admin page.
7839
8827
 
7840
8828
  Args:
7841
8829
  expand_workspace_info (bool, optional):
@@ -9527,7 +10515,9 @@ class OTCS:
9527
10515
  method="GET",
9528
10516
  headers=request_header,
9529
10517
  timeout=None,
9530
- failure_message="Failed to get workspace members",
10518
+ failure_message="Failed to get workspace members for workspace with ID -> {} and role with ID -> {}".format(
10519
+ workspace_id, role_id
10520
+ ),
9531
10521
  )
9532
10522
 
9533
10523
  # end method definition
@@ -9891,11 +10881,14 @@ class OTCS:
9891
10881
  """Get definition information for Unique Names.
9892
10882
 
9893
10883
  Args:
9894
- names (list): list of unique names to lookup.
9895
- subtype (int): filter unique names for those pointing to a specific subtype
10884
+ names (list):
10885
+ A list of unique names to lookup.
10886
+ subtype (int):
10887
+ A subtype ID to filter unique names to those pointing to a specific subtype.
9896
10888
 
9897
10889
  Returns:
9898
- dict | None: Unique name definition information or None if REST call fails.
10890
+ dict | None:
10891
+ Unique name definition information or None if REST call fails.
9899
10892
 
9900
10893
  Example:
9901
10894
  ```json
@@ -10555,7 +11548,7 @@ class OTCS:
10555
11548
  description: str = "",
10556
11549
  show_error: bool = True,
10557
11550
  ) -> dict | None:
10558
- """Create an Extended ECM wiki page.
11551
+ """Create an OTCS wiki page.
10559
11552
 
10560
11553
  Args:
10561
11554
  wiki_id (int):
@@ -10752,11 +11745,11 @@ class OTCS:
10752
11745
  ) -> dict | None:
10753
11746
  """Assign an Content Server item to users and groups.
10754
11747
 
10755
- This is a function used by Extended ECM for Government.
11748
+ This is a function used by OT Content Management for Government.
10756
11749
 
10757
11750
  Args:
10758
11751
  node_id (int):
10759
- The node ID of the Extended ECM item (e.g. a workspace or a document)
11752
+ The node ID of the OTCS item (e.g. a workspace or a document)
10760
11753
  subject (str):
10761
11754
  The title / subject of the assignment.
10762
11755
  instruction (str):
@@ -10895,7 +11888,7 @@ class OTCS:
10895
11888
  or both.
10896
11889
 
10897
11890
  Args:
10898
- node_id (int): The ID of the Extended ECM item (node) to which permissions are being assigned.
11891
+ node_id (int): The ID of the OTCS item (node) to which permissions are being assigned.
10899
11892
  permissions (list of str): A list of permissions to assign to the assignee. Valid permissions include:
10900
11893
  - "see" : View the item
10901
11894
  - "see_contents" : View the contents of the item
@@ -11446,8 +12439,10 @@ class OTCS:
11446
12439
  throw an error.
11447
12440
 
11448
12441
  Args:
11449
- node_id (int): node ID to apply the category to
11450
- category_id (list): ID of the category definition object
12442
+ node_id (int):
12443
+ The node ID to apply the category to.
12444
+ category_id (list):
12445
+ The ID of the category definition object.
11451
12446
  inheritance (bool | None):
11452
12447
  If True, turn on inheritance for the category
11453
12448
  (this makes only sense if the node is a container like a folder or workspace).
@@ -13630,11 +14625,14 @@ class OTCS:
13630
14625
  """Get a list of available workflows for a document ID and a parent ID.
13631
14626
 
13632
14627
  Args:
13633
- node_id (int): node ID of the document
13634
- parent_id (int): node ID of the parent
14628
+ node_id (int):
14629
+ The node ID of the document.
14630
+ parent_id (int):
14631
+ The node ID of the parent.
13635
14632
 
13636
14633
  Returns:
13637
- list: list of available workflows
14634
+ list:
14635
+ The list of available workflows.
13638
14636
 
13639
14637
  Example:
13640
14638
  ```json
@@ -14665,44 +15663,295 @@ class OTCS:
14665
15663
 
14666
15664
  # end method definition
14667
15665
 
14668
- def volume_translator(
15666
+ def traverse_node(
14669
15667
  self,
14670
- current_node_id: int,
14671
- translator: object,
14672
- languages: list,
14673
- simulate: bool = False,
14674
- ) -> None:
14675
- """Experimental code to translate the item names and descriptions in a hierarchy.
15668
+ node: dict | int,
15669
+ executables: list[callable],
15670
+ current_depth: int = 0,
15671
+ **kwargs: dict,
15672
+ ) -> dict:
15673
+ """Recursively traverse the node an its subnodes.
15674
+
15675
+ This method is preferred for CPU intensive traversals.
15676
+
15677
+ Args:
15678
+ node (dict | int):
15679
+ The node datastructure (like in a V2 REST Call response)
15680
+ executables (list[callable]):
15681
+ A list of methods to call for each traversed node. The node
15682
+ and a optional dictionary of keyword arguments (kwargs)
15683
+ are passed. The executables are called BEFORE the subnodes
15684
+ are traversed. The executables should return a boolean result.
15685
+ If the result is False, then the execution of the executables
15686
+ list is stopped.
15687
+ current_depth (int, optional):
15688
+ The recursion depth - distance in hierarchy from the root note
15689
+ traverse_node() was INITIALLY called from.
15690
+ kwargs:
15691
+ Additional keyword arguments for the executables.
15692
+
15693
+ Returns:
15694
+ dict: {
15695
+ "processed": int,
15696
+ "traversed": int,
15697
+ }
15698
+
15699
+ """
15700
+
15701
+ processed = 0
15702
+ traversed = 0
15703
+
15704
+ # Initialze the traverse flag. If True, container
15705
+ # subnodes will be processed. If executables exist
15706
+ # than at least one executable has to indicate that
15707
+ # further traversal is required:
15708
+ traverse = not (executables)
15709
+
15710
+ if isinstance(node, dict):
15711
+ node_id = self.get_result_value(response=node, key="id")
15712
+ elif isinstance(node, int):
15713
+ node_id = node
15714
+ node = self.get_node(node_id=node_id)
15715
+ else:
15716
+ self.logger.error("Illegal type of node object. Expect 'int' or 'dict'!")
15717
+ return (False, False)
15718
+
15719
+ # Run executables:
15720
+ for executable in executables:
15721
+ result_success, result_traverse = executable(node=node, current_depth=current_depth, **kwargs)
15722
+ if result_traverse:
15723
+ traverse = True
15724
+ if not result_success:
15725
+ break
15726
+ else:
15727
+ # else case is processed only if NO break occured in the for loop
15728
+ # If all executables have been successful than the node counts as processed:
15729
+ processed += 1
15730
+
15731
+ node_type = self.get_result_value(response=node, key="type")
15732
+
15733
+ # We only traverse the subtnodes if the current node is a container type
15734
+ # and the executables have all been executed successfully:
15735
+ if traverse and node_type in self.CONTAINER_ITEM_TYPES:
15736
+ # Get children nodes of the current node:
15737
+ subnodes = self.get_subnodes_iterator(parent_node_id=node_id, page_size=200)
15738
+
15739
+ # Recursive call of all subnodes:
15740
+ for subnode in subnodes:
15741
+ subnode_id = self.get_result_value(response=subnode, key="id")
15742
+ subnode_name = self.get_result_value(response=subnode, key="name")
15743
+ self.logger.info("Traversing node -> '%s' (%s)", subnode_name, str(subnode_id))
15744
+ # Recursive call for current subnode:
15745
+ result = self.traverse_node(
15746
+ node=subnode,
15747
+ executables=executables,
15748
+ current_depth=current_depth + 1,
15749
+ **kwargs,
15750
+ )
15751
+ processed += result.get("processed", 0)
15752
+ traversed += result.get("traversed", 0)
15753
+ traversed += 1
15754
+
15755
+ return {"processed": processed, "traversed": traversed}
15756
+
15757
+ # end method definition
15758
+
15759
+ def traverse_node_parallel(
15760
+ self,
15761
+ node: dict | int,
15762
+ executables: list[callable],
15763
+ workers: int = 3,
15764
+ strategy: str = "BFS",
15765
+ timeout: float = 1.0,
15766
+ **kwargs: dict,
15767
+ ) -> dict:
15768
+ """Traverse nodes using a queue and thread pool (BFS-style).
15769
+
15770
+ This method is preferred for I/O or API intensive traversals.
15771
+
15772
+ Args:
15773
+ node (dict | int):
15774
+ Root node to start traversal. It can be a node or a node ID.
15775
+ executables (list[callable]):
15776
+ Callables to execute per node.
15777
+ workers (int, optional):
15778
+ Number of parallel workers.
15779
+ strategy (str, optional):
15780
+ Either "DFS" for Depth First Search, or "BFS" for Breadth First Search.
15781
+ "BFS" is the default.
15782
+ timeout (float, optional):
15783
+ Wait time for the queue to have items:
15784
+ kwargs (dict):
15785
+ Additional arguments for executables.
15786
+
15787
+ Returns:
15788
+ dict:
15789
+ Stats with processed and traversed counters.
15790
+
15791
+ """
15792
+
15793
+ results = {"processed": 0, "traversed": 0}
15794
+ lock = threading.Lock()
15795
+ if strategy == "BFS":
15796
+ task_queue = Queue()
15797
+ elif strategy == "DFS":
15798
+ task_queue = LifoQueue()
15799
+
15800
+ # Enqueue initial nodes at depth 0:
15801
+ node_id = self.get_result_value(response=node, key="id") if isinstance(node, dict) else node
15802
+ subnodes = self.get_subnodes_iterator(parent_node_id=node_id, page_size=100)
15803
+ for subnode in subnodes:
15804
+ # Each queue element needs its own copy of traversal data:
15805
+ traversal_data = {
15806
+ "folder_path": [],
15807
+ "workspace_id": None,
15808
+ "workspace_type": None,
15809
+ "workspace_name": None,
15810
+ "workspace_description": None,
15811
+ "current_depth": 0,
15812
+ }
15813
+ task_queue.put((subnode, 0, traversal_data))
15814
+
15815
+ def traverse_node_worker() -> None:
15816
+ """Work on queue.
15817
+
15818
+ Returns:
15819
+ None
15820
+
15821
+ """
15822
+
15823
+ thread_name = threading.current_thread().name
15824
+
15825
+ while True:
15826
+ # Initialze the traverse flag. If True, container
15827
+ # subnodes will be processed. If executables exist
15828
+ # than at least one executable has to return that
15829
+ # further traversal is required:
15830
+ traverse = not (executables)
15831
+
15832
+ try:
15833
+ node, current_depth, traversal_data = task_queue.get(timeout=timeout)
15834
+ except Empty:
15835
+ self.logger.info("[%s] No (more) nodes to process - finishing...", thread_name)
15836
+ return # Queue is empty - worker is done
15837
+
15838
+ try:
15839
+ # Fetch node dictionary if just an ID was passed as parameter:
15840
+ if isinstance(node, int):
15841
+ node = self.get_node(node_id=node)
15842
+
15843
+ node_id = self.get_result_value(response=node, key="id")
15844
+ node_name = self.get_result_value(response=node, key="name")
15845
+ node_type = self.get_result_value(response=node, key="type")
15846
+
15847
+ self.logger.info(
15848
+ "[%s] Traversing node -> '%s' (%s) at depth %d", thread_name, node_name, node_id, current_depth
15849
+ )
15850
+
15851
+ # Run all executables
15852
+ for executable in executables:
15853
+ try:
15854
+ result_success, result_traverse = executable(
15855
+ node=node,
15856
+ current_depth=current_depth,
15857
+ traversal_data=traversal_data,
15858
+ **kwargs,
15859
+ )
15860
+ if result_traverse:
15861
+ traverse = True
15862
+ if not result_success:
15863
+ break
15864
+ except Exception as e:
15865
+ self.logger.error("Failed to run executable on node -> '%s' (%s), error -> %s", node_name, node_id, str(e))
15866
+ else:
15867
+ with lock:
15868
+ results["processed"] += 1
15869
+
15870
+ # We only traverse the subtnodes if the current node is a container type
15871
+ # and at least one executables (if they any) indicate to require further traversal:
15872
+ if traverse and node_type in self.CONTAINER_ITEM_TYPES:
15873
+ subnodes = self.get_subnodes_iterator(parent_node_id=node_id, page_size=100)
15874
+ for subnode in subnodes:
15875
+ sub_traversal_data = {
15876
+ **traversal_data,
15877
+ "folder_path": traversal_data["folder_path"] + [node_name],
15878
+ "current_depth": current_depth + 1,
15879
+ }
15880
+ task_queue.put((subnode, current_depth + 1, sub_traversal_data))
15881
+
15882
+ with lock:
15883
+ results["traversed"] += 1
15884
+
15885
+ finally:
15886
+ # Guarantee task_done() is called even if exceptions occur:
15887
+ task_queue.task_done()
15888
+
15889
+ # end method traverse_node_worker()
15890
+
15891
+ # Start thread pool with limited concurrency
15892
+ with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="Traversal_Worker") as executor:
15893
+ for i in range(workers):
15894
+ self.logger.info("Starting worker -> %d...", i)
15895
+ executor.submit(traverse_node_worker)
15896
+
15897
+ # Wait for all tasks to complete
15898
+ task_queue.join()
15899
+
15900
+ return results
15901
+
15902
+ # end method definition
15903
+
15904
+ def translate_node(self, node: dict | int, **kwargs: dict) -> bool:
15905
+ """Translate a node.
14676
15906
 
14677
15907
  The actual translation is done by a tranlator object. This recursive method just
14678
15908
  traverses the hierarchy and calls the translate() method of the translator object.
14679
15909
 
14680
15910
  Args:
14681
- current_node_id (int):
14682
- The current node ID to translate.
14683
- translator (object):
14684
- This object needs to be created based on the "Translator" class
14685
- and passed to this method.
14686
- languages (list):
14687
- A list of target languages to translate into.
14688
- simulate (bool, optional):
14689
- If True, do not really rename but just traverse and log info.
14690
- The default is False.
15911
+ node (dict | int):
15912
+ The current node to translate. This can be the node data structure or just
15913
+ the node ID. If it is just the ID the actual node will be fetched.
15914
+ kwargs (dict):
15915
+ Keyword parameters. The methods expects the follwoing keyword parameters:
15916
+ * simulate (bool):
15917
+ If True, do not really rename but just traverse and log info.
15918
+ * translator (object):
15919
+ This object needs to be created based on the "Translator" class
15920
+ and passed to this method.
15921
+ * languages (list):
15922
+ A list of target languages to translate into.
15923
+
15924
+ Returns:
15925
+ bool:
15926
+ True for success, False for error.
14691
15927
 
14692
15928
  """
14693
15929
 
14694
- # Get current node based on the ID:
14695
- current_node = self.get_node(current_node_id)
14696
- current_node_id = self.get_result_value(response=current_node, key="id")
15930
+ translator = kwargs.get("translator")
15931
+ languages = kwargs.get("languages", [])
15932
+ simulate = kwargs.get("simulate", False)
15933
+
15934
+ if not translator:
15935
+ self.logger.error("Missing 'translator' parameter (object)!")
15936
+ return False
15937
+ if not languages:
15938
+ self.logger.error("Missing or empty 'languages' parameter (list)!")
15939
+ return False
15940
+
15941
+ if isinstance(node, dict):
15942
+ current_node_id = self.get_result_value(response=node, key="id")
15943
+ else:
15944
+ current_node_id = node
15945
+ node = self.get_node(node_id=current_node_id)
14697
15946
 
14698
- name = self.get_result_value(response=current_node, key="name")
14699
- description = self.get_result_value(response=current_node, key="description")
15947
+ name = self.get_result_value(response=node, key="name")
15948
+ description = self.get_result_value(response=node, key="description")
14700
15949
  names_multilingual = self.get_result_value(
14701
- response=current_node,
15950
+ response=node,
14702
15951
  key="name_multilingual",
14703
15952
  )
14704
15953
  descriptions_multilingual = self.get_result_value(
14705
- response=current_node,
15954
+ response=node,
14706
15955
  key="description_multilingual",
14707
15956
  )
14708
15957
 
@@ -14717,7 +15966,7 @@ class OTCS:
14717
15966
  language,
14718
15967
  names_multilingual["en"],
14719
15968
  )
14720
- self.logger.debug(
15969
+ self.logger.info(
14721
15970
  "Translate name of node -> %s from -> '%s' (%s) to -> '%s' (%s)",
14722
15971
  current_node_id,
14723
15972
  name,
@@ -14735,7 +15984,7 @@ class OTCS:
14735
15984
  language,
14736
15985
  descriptions_multilingual["en"],
14737
15986
  )
14738
- self.logger.debug(
15987
+ self.logger.info(
14739
15988
  "Translate description of node -> %s from -> '%s' (%s) to -> '%s' (%s)",
14740
15989
  current_node_id,
14741
15990
  descriptions_multilingual["en"],
@@ -14746,24 +15995,17 @@ class OTCS:
14746
15995
 
14747
15996
  # Rename node multi-lingual:
14748
15997
  if not simulate:
14749
- self.rename_node(
15998
+ response = self.rename_node(
14750
15999
  node_id=current_node_id,
14751
16000
  name=name,
14752
16001
  description=description,
14753
16002
  name_multilingual=names_multilingual,
14754
16003
  description_multilingual=descriptions_multilingual,
14755
16004
  )
16005
+ if not response:
16006
+ return False
14756
16007
 
14757
- # Get children nodes of the current node:
14758
- results = self.get_subnodes(parent_node_id=current_node_id, limit=200)["results"]
14759
-
14760
- # Recursive call of all subnodes:
14761
- for result in results:
14762
- self.volume_translator(
14763
- current_node_id=result["data"]["properties"]["id"],
14764
- translator=translator,
14765
- languages=languages,
14766
- )
16008
+ return True
14767
16009
 
14768
16010
  # end method definition
14769
16011
 
@@ -15601,10 +16843,12 @@ class OTCS:
15601
16843
  subnode["id"],
15602
16844
  subnode["type"],
15603
16845
  )
16846
+ # end match subnode["type"]:
15604
16847
 
15605
16848
  # Wait for all download threads to complete:
15606
16849
  for thread in download_threads:
15607
16850
  thread.join()
16851
+ # end for subnode in subnodes:
15608
16852
 
15609
16853
  # Wait for all traversal threads to complete:
15610
16854
  for thread in traversal_threads:
@@ -15614,6 +16858,481 @@ class OTCS:
15614
16858
 
15615
16859
  # end method definition
15616
16860
 
16861
+ def load_items_new(
16862
+ self,
16863
+ node_id: int,
16864
+ filter_workspace_depth: int | None = None,
16865
+ filter_workspace_subtypes: list | None = None,
16866
+ filter_workspace_category: str | None = None,
16867
+ filter_workspace_attributes: dict | list | None = None,
16868
+ filter_item_depth: int | None = None,
16869
+ filter_item_subtypes: list | None = None,
16870
+ filter_item_category: str | None = None,
16871
+ filter_item_attributes: dict | list | None = None,
16872
+ filter_item_in_workspace: bool = True,
16873
+ exclude_node_ids: list | None = None,
16874
+ workspace_metadata: bool = True,
16875
+ item_metadata: bool = True,
16876
+ download_documents: bool = True,
16877
+ skip_existing_downloads: bool = True,
16878
+ extract_zip: bool = False,
16879
+ workers: int = 3,
16880
+ ) -> dict | None:
16881
+ """Create a Pandas Data Frame by traversing a given Content Server hierarchy.
16882
+
16883
+ This method collects workspace and document items.
16884
+
16885
+ Args:
16886
+ node_id (int):
16887
+ The root Node ID the traversal should start at.
16888
+ filter_workspace_depth (int | None, optional):
16889
+ Additive filter criterium for workspace path depth.
16890
+ Defaults to None = filter not active.
16891
+ filter_workspace_subtypes (list | None, optional):
16892
+ Additive filter criterium for workspace type.
16893
+ Defaults to None = filter not active.
16894
+ filter_workspace_category (str | None, optional):
16895
+ Additive filter criterium for workspace category.
16896
+ Defaults to None = filter not active.
16897
+ filter_workspace_attributes (dict | list, optional):
16898
+ Additive filter criterium for workspace attribute values.
16899
+ Defaults to None = filter not active
16900
+ filter_item_depth (int | None, optional):
16901
+ Additive filter criterium for item path depth.
16902
+ Defaults to None = filter not active.
16903
+ filter_item_subtypes (list | None, optional):
16904
+ Additive filter criterium for item types.
16905
+ Defaults to None = filter not active.
16906
+ filter_item_category (str | None, optional):
16907
+ Additive filter criterium for item category.
16908
+ Defaults to None = filter not active.
16909
+ filter_item_attributes (dict | list, optional):
16910
+ Additive filter criterium for item attribute values.
16911
+ Defaults to None = filter not active.
16912
+ filter_item_in_workspace (bool, optional):
16913
+ Defines if item filters should be applied to
16914
+ items inside workspaces as well. If False,
16915
+ then items inside workspaces are always included.
16916
+ exclude_node_ids (list, optional):
16917
+ List of node IDs to exclude from traversal.
16918
+ workspace_metadata (bool, optional):
16919
+ If True, include workspace metadata.
16920
+ item_metadata (bool, optional):
16921
+ if True, include item metadata.
16922
+ download_documents (bool, optional):
16923
+ Whether or not documents should be downloaded.
16924
+ skip_existing_downloads (bool, optional):
16925
+ If True, reuse already existing downloads in the file system.
16926
+ extract_zip (bool, optional):
16927
+ If True, documents that are downloaded with mime-type
16928
+ "application/x-zip-compressed" will be extracted recursively.
16929
+ workers (int, optional):
16930
+ Number of worker threads to start.
16931
+
16932
+ Returns:
16933
+ dict:
16934
+ Stats with processed and traversed counters.
16935
+
16936
+ """
16937
+
16938
+ # Initiaze download threads for this subnode:
16939
+ download_threads = []
16940
+
16941
+ def check_node_exclusions(node: dict, **kwargs: dict) -> tuple[bool, bool]:
16942
+ """Check if the processed node is on the exclusion list.
16943
+
16944
+ Stop processing and traversing if the node is excluded.
16945
+
16946
+ Args:
16947
+ node (dict):
16948
+ The current node being processed.
16949
+ kwargs (dict):
16950
+ Additional keyword arguments that are specific for the method.
16951
+
16952
+ Returns:
16953
+ tuple[bool, bool]:
16954
+ success (bool) - if node was processed successfully
16955
+ traverse (bool) - if subnodes should be processed
16956
+
16957
+ """
16958
+
16959
+ exclude_node_ids = kwargs.get("exclude_node_ids")
16960
+ if exclude_node_ids is None:
16961
+ self.logger.error("Missing keyword arguments for executable in node traversal!")
16962
+ return (False, False)
16963
+
16964
+ node_id = self.get_result_value(response=node, key="id")
16965
+ node_name = self.get_result_value(response=node, key="name")
16966
+
16967
+ if node_id and exclude_node_ids is not None and (node_id in exclude_node_ids):
16968
+ self.logger.info(
16969
+ "Node -> '%s' (%s) is in exclusion list. Skip traversal of this node.",
16970
+ node_name,
16971
+ node_id,
16972
+ )
16973
+ return (False, False)
16974
+ return (True, True)
16975
+
16976
+ # end check_node_exclusions()
16977
+
16978
+ def check_node_workspace(node: dict, **kwargs: dict) -> tuple[bool, bool]:
16979
+ """Check if the processed node should be recorded as a workspace in the data frame.
16980
+
16981
+ Args:
16982
+ node (dict):
16983
+ The current node being processed.
16984
+ kwargs (dict):
16985
+ Additional keyword arguments that are specific for the method.
16986
+
16987
+ Returns:
16988
+ tuple[bool, bool]:
16989
+ success (bool) - if node was processed successfully
16990
+ traverse (bool) - if subnodes should be processed
16991
+
16992
+ """
16993
+
16994
+ traversal_data = kwargs.get("traversal_data")
16995
+ filter_workspace_data = kwargs.get("filter_workspace_data")
16996
+ control_flags = kwargs.get("control_flags")
16997
+
16998
+ if not traversal_data or not filter_workspace_data or not control_flags:
16999
+ self.logger.error("Missing keyword arguments for executable in node traversal!")
17000
+ return False
17001
+
17002
+ node_id = self.get_result_value(response=node, key="id")
17003
+ node_name = self.get_result_value(response=node, key="name")
17004
+ node_description = self.get_result_value(response=node, key="description")
17005
+ node_type = self.get_result_value(response=node, key="type")
17006
+
17007
+ #
17008
+ # 1. Check if the traversal is already inside a workflow. Then we can skip
17009
+ # the workspace processing. We currently don't support sub-workspaces.
17010
+ #
17011
+ workspace_id = traversal_data["workspace_id"]
17012
+ if workspace_id:
17013
+ self.logger.debug(
17014
+ "Found folder or workspace -> '%s' (%s) inside workspace with ID -> %s. So this container cannot be a workspace.",
17015
+ node_name,
17016
+ node_id,
17017
+ workspace_id,
17018
+ )
17019
+ # Success = False, Traverse = True
17020
+ return (False, True)
17021
+
17022
+ #
17023
+ # 2. Check if metadata is required (either for columns or for filters)
17024
+ #
17025
+ if (
17026
+ control_flags["workspace_metadata"]
17027
+ or filter_workspace_data["filter_workspace_category"]
17028
+ or filter_workspace_data["filter_workspace_attributes"]
17029
+ ):
17030
+ categories = self.get_node_categories(
17031
+ node_id=node_id,
17032
+ metadata=(
17033
+ filter_workspace_data["filter_workspace_category"] is not None
17034
+ or filter_workspace_data["filter_workspace_attributes"] is not None
17035
+ or not self._use_numeric_category_identifier
17036
+ ),
17037
+ )
17038
+ else:
17039
+ categories = None
17040
+
17041
+ #
17042
+ # 3. Apply the defined filters to the current node to see
17043
+ # if we want to 'interpret' it as a workspace
17044
+ #
17045
+ # See if it is a node that we want to interpret as a workspace.
17046
+ # Only "workspaces" that comply with ALL provided filters are
17047
+ # considered and written into the data frame as a workspace row:
17048
+ # Root nodes may have a "results" dict. The subnode iterators don't have it:
17049
+ node_properties = node["results"]["data"]["properties"] if "results" in node else node["data"]["properties"]
17050
+ if not self.apply_filter(
17051
+ node=node_properties,
17052
+ node_categories=categories,
17053
+ current_depth=traversal_data["current_depth"],
17054
+ filter_depth=filter_workspace_data["filter_workspace_depth"],
17055
+ filter_subtypes=filter_workspace_data["filter_workspace_subtypes"],
17056
+ filter_category=filter_workspace_data["filter_workspace_category"],
17057
+ filter_attributes=filter_workspace_data["filter_workspace_attributes"],
17058
+ ):
17059
+ # Success = False, Traverse = True
17060
+ return (False, True)
17061
+
17062
+ self.logger.debug(
17063
+ "Found workspace -> '%s' (%s) in depth -> %s.",
17064
+ node_name,
17065
+ node_id,
17066
+ traversal_data["current_depth"],
17067
+ )
17068
+
17069
+ #
17070
+ # 4. Create the data frame row from the node / traversal data:
17071
+ #
17072
+ row = {}
17073
+ row["workspace_type"] = node_type
17074
+ row["workspace_id"] = node_id
17075
+ row["workspace_name"] = node_name
17076
+ row["workspace_description"] = node_description
17077
+ row["workspace_outer_path"] = traversal_data["folder_path"]
17078
+ # If we want (and have) metadata then add it as columns:
17079
+ if control_flags["workspace_metadata"] and categories and categories.get("results", None):
17080
+ # Add columns for workspace node categories have been determined above.
17081
+ self.add_attribute_columns(row=row, categories=categories, prefix="workspace_cat_")
17082
+
17083
+ # Now we add the article to the Pandas Data Frame in the Data class:
17084
+ with self._data.lock():
17085
+ self._data.append(row)
17086
+
17087
+ #
17088
+ # 5. Update the traversal data:
17089
+ #
17090
+ traversal_data["workspace_id"] = node_id
17091
+ traversal_data["workspace_name"] = node_name
17092
+ traversal_data["workspace_type"] = node_type
17093
+ traversal_data["workspace_description"] = node_description
17094
+ self.logger.debug("Updated traversal data -> %s", str(traversal_data))
17095
+
17096
+ # Success = True, Traverse = False
17097
+ # We have traverse = True because we need to
17098
+ # keep traversing into the workspace folders.
17099
+ return (True, True)
17100
+
17101
+ # end check_node_workspace()
17102
+
17103
+ def check_node_item(node: dict, **kwargs: dict) -> tuple[bool, bool]:
17104
+ """Check if the processed node should be recorded as an item in the data frame.
17105
+
17106
+ Args:
17107
+ node (dict):
17108
+ The current node being processed.
17109
+ kwargs (dict):
17110
+ Additional keyword arguments that are specific for the method.
17111
+
17112
+ Returns:
17113
+ tuple[bool, bool]:
17114
+ success (bool) - if node was processed successfully
17115
+ traverse (bool) - if subnodes should be processed
17116
+
17117
+ """
17118
+
17119
+ traversal_data = kwargs.get("traversal_data")
17120
+ filter_item_data = kwargs.get("filter_item_data")
17121
+ control_flags = kwargs.get("control_flags")
17122
+
17123
+ if not traversal_data or not filter_item_data or not control_flags:
17124
+ self.logger.error("Missing keyword arguments for executable in node item traversal!")
17125
+ return (False, False)
17126
+
17127
+ node_id = self.get_result_value(response=node, key="id")
17128
+ node_name = self.get_result_value(response=node, key="name")
17129
+ node_description = self.get_result_value(response=node, key="description")
17130
+ node_type = self.get_result_value(response=node, key="type")
17131
+
17132
+ current_depth = traversal_data["current_depth"]
17133
+ folder_path = traversal_data["folder_path"]
17134
+ workspace_id = traversal_data["workspace_id"]
17135
+ workspace_name = traversal_data["workspace_name"]
17136
+ workspace_description = traversal_data["workspace_description"]
17137
+ workspace_type = traversal_data["workspace_type"]
17138
+
17139
+ #
17140
+ # 1. Check if metadata is required (either for columns or for filters)
17141
+ #
17142
+ if (
17143
+ control_flags["item_metadata"]
17144
+ or filter_item_data["filter_item_category"]
17145
+ or filter_item_data["filter_item_attributes"]
17146
+ ):
17147
+ categories = self.get_node_categories(
17148
+ node_id=node_id,
17149
+ metadata=(
17150
+ filter_item_data["filter_item_category"] is not None
17151
+ or filter_item_data["filter_item_attributes"] is not None
17152
+ or not self._use_numeric_category_identifier
17153
+ ),
17154
+ )
17155
+ else:
17156
+ categories = None
17157
+
17158
+ #
17159
+ # 2. Apply the defined filters to the current node to see
17160
+ # if we want to add it to the data frame as an item.
17161
+ #
17162
+ # If filter_item_in_workspace is false, then documents
17163
+ # inside workspaces are included in the data frame unconditionally!
17164
+ # We apply the defined filters to the current node. Only "documents"
17165
+ # that comply with ALL provided filters are considered and written into the data frame
17166
+ node_properties = node["results"]["data"]["properties"] if "results" in node else node["data"]["properties"]
17167
+ if (not workspace_id or filter_item_in_workspace) and not self.apply_filter(
17168
+ node=node_properties,
17169
+ node_categories=categories,
17170
+ current_depth=current_depth,
17171
+ filter_depth=filter_item_data["filter_item_depth"],
17172
+ filter_subtypes=filter_item_data["filter_item_subtypes"],
17173
+ filter_category=filter_item_data["filter_item_category"],
17174
+ filter_attributes=filter_item_data["filter_item_attributes"],
17175
+ ):
17176
+ # Success = False, Traverse = True
17177
+ return (False, True)
17178
+
17179
+ # We only consider documents that are inside the defined "workspaces":
17180
+ if workspace_id:
17181
+ self.logger.debug(
17182
+ "Found %s item -> '%s' (%s) in depth -> %s inside workspace -> '%s' (%s).",
17183
+ "document" if node_type == self.ITEM_TYPE_DOCUMENT else "URL",
17184
+ node_name,
17185
+ node_id,
17186
+ current_depth,
17187
+ workspace_name,
17188
+ workspace_id,
17189
+ )
17190
+ else:
17191
+ self.logger.debug(
17192
+ "Found %s item -> '%s' (%s) in depth -> %s outside of workspace.",
17193
+ "document" if node_type == self.ITEM_TYPE_DOCUMENT else "URL",
17194
+ node_name,
17195
+ node_id,
17196
+ current_depth,
17197
+ )
17198
+
17199
+ # Special handling for documents: download them if requested:
17200
+ if node_type == self.ITEM_TYPE_DOCUMENT:
17201
+ # We use the node ID as the filename to avoid any
17202
+ # issues with too long or not valid file names.
17203
+ # As the Pandas DataFrame has all information
17204
+ # this is easy to resolve at upload time.
17205
+ file_path = "{}/{}".format(self._download_dir, node_id)
17206
+
17207
+ # We download only if not downloaded before or if downloaded
17208
+ # before but forced to re-download:
17209
+ if control_flags["download_documents"] and (
17210
+ not os.path.exists(file_path) or not control_flags["skip_existing_downloads"]
17211
+ ):
17212
+ #
17213
+ # Start anasynchronous Download Thread:
17214
+ #
17215
+ self.logger.debug(
17216
+ "Downloading file -> '%s'...",
17217
+ file_path,
17218
+ )
17219
+
17220
+ extract_after_download = node["mime_type"] == "application/x-zip-compressed" and extract_zip
17221
+ thread = threading.Thread(
17222
+ target=self.download_document_multi_threading,
17223
+ args=(node_id, file_path, extract_after_download),
17224
+ name="download_document_node_{}".format(node_id),
17225
+ )
17226
+ thread.start()
17227
+ download_threads.append(thread)
17228
+ else:
17229
+ self.logger.debug(
17230
+ "File -> %s has been downloaded before or download is not requested. Skipping download...",
17231
+ file_path,
17232
+ )
17233
+ # end if document
17234
+
17235
+ #
17236
+ # Construct a dictionary 'row' that we will add
17237
+ # to the resulting data frame:
17238
+ #
17239
+ row = {}
17240
+ # First we include some key workspace data to associate
17241
+ # the item with the workspace:
17242
+ row["workspace_type"] = workspace_type
17243
+ row["workspace_id"] = workspace_id
17244
+ row["workspace_name"] = workspace_name
17245
+ row["workspace_description"] = workspace_description
17246
+ # Then add item specific data:
17247
+ row["item_id"] = str(node_id)
17248
+ row["item_type"] = node_type
17249
+ row["item_name"] = node_name
17250
+ row["item_description"] = node_description
17251
+ # We take the sub-path of the folder path inside the workspace
17252
+ # as the item path:
17253
+ try:
17254
+ # Item path are the list elements after the item that is the workspace name:
17255
+ row["item_path"] = folder_path[folder_path.index(workspace_name) + 1 :]
17256
+ except ValueError:
17257
+ self.logger.warning("Cannot access folder path while processing -> '%s' (%s)!", node_name, node_id)
17258
+ row["item_path"] = []
17259
+ row["item_download_name"] = str(node_id) if node_type == self.ITEM_TYPE_DOCUMENT else ""
17260
+ row["item_mime_type"] = (
17261
+ self.get_result_value(response=node, key="mime_type") if node_type == self.ITEM_TYPE_DOCUMENT else ""
17262
+ )
17263
+ # URL specific data:
17264
+ row["item_url"] = (
17265
+ self.get_result_value(response=node, key="mime_type") if node_type == self.ITEM_TYPE_URL else ""
17266
+ )
17267
+ if item_metadata and categories and categories["results"]:
17268
+ # Add columns for workspace node categories have been determined above.
17269
+ self.add_attribute_columns(row=row, categories=categories, prefix="item_cat_")
17270
+
17271
+ # Now we add the row to the Pandas Data Frame in the Data class:
17272
+ self.logger.info(
17273
+ "Adding %s -> '%s' (%s) to data frame...",
17274
+ "document" if node_type == self.ITEM_TYPE_DOCUMENT else "URL",
17275
+ row["item_name"],
17276
+ row["item_id"],
17277
+ )
17278
+ with self._data.lock():
17279
+ self._data.append(row)
17280
+
17281
+ return True
17282
+
17283
+ # end check_node_item()
17284
+
17285
+ #
17286
+ # Start Main method:
17287
+ #
17288
+
17289
+ # Create folder if it does not exist
17290
+ if download_documents and not os.path.exists(self._download_dir):
17291
+ os.makedirs(self._download_dir)
17292
+
17293
+ # These won't change during processing - stays the same for all nodes:
17294
+ filter_workspace_data = {
17295
+ "filter_workspace_depth": filter_workspace_depth,
17296
+ "filter_workspace_subtypes": filter_workspace_subtypes,
17297
+ "filter_workspace_category": filter_workspace_category,
17298
+ "filter_workspace_attributes": filter_workspace_attributes,
17299
+ }
17300
+
17301
+ # These won't change during processing - stays the same for all nodes:
17302
+ filter_item_data = {
17303
+ "filter_item_depth": filter_item_depth,
17304
+ "filter_item_subtypes": filter_item_subtypes,
17305
+ "filter_item_category": filter_item_category,
17306
+ "filter_item_attributes": filter_item_attributes,
17307
+ "filter_item_in_workspace": filter_item_in_workspace,
17308
+ }
17309
+
17310
+ # These won't change during processing - stays the same for all nodes:
17311
+ control_flags = {
17312
+ "workspace_metadata": workspace_metadata,
17313
+ "item_metadata": item_metadata,
17314
+ "download_documents": download_documents,
17315
+ "skip_existing_downloads": skip_existing_downloads,
17316
+ "extract_zip": extract_zip,
17317
+ }
17318
+
17319
+ #
17320
+ # Start the traversal of the nodes:
17321
+ #
17322
+ result = self.traverse_node_parallel(
17323
+ node=node_id,
17324
+ executables=[check_node_exclusions, check_node_workspace, check_node_item],
17325
+ exclude_node_ids=exclude_node_ids,
17326
+ filter_workspace_data=filter_workspace_data,
17327
+ filter_item_data=filter_item_data,
17328
+ control_flags=control_flags,
17329
+ workers=workers,
17330
+ )
17331
+
17332
+ return result
17333
+
17334
+ # end method definition
17335
+
15617
17336
  def aviator_embed_metadata(
15618
17337
  self,
15619
17338
  node_id: int,
@@ -15641,7 +17360,7 @@ class OTCS:
15641
17360
  Defines if the method waits for the completion of the embedding. Defaults to True.
15642
17361
  message_override (dict | None, optional):
15643
17362
  Overwrite specific message details. Defaults to None.
15644
- timeout (float):
17363
+ timeout (float, optional):
15645
17364
  Time in seconds to wait until the WebSocket times out. Defaults to 10.0.
15646
17365
  document_metadata (bool, optional):
15647
17366
  Defines whether or not to embed document metadata.