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/coreshare.py +71 -5
- pyxecm/customizer/api/app.py +5 -11
- pyxecm/customizer/api/common/payload_list.py +39 -10
- pyxecm/customizer/api/common/router.py +8 -6
- pyxecm/customizer/api/settings.py +9 -0
- pyxecm/customizer/api/v1_otcs/router.py +16 -6
- pyxecm/customizer/payload.py +109 -73
- pyxecm/customizer/translate.py +14 -10
- pyxecm/helper/data.py +12 -20
- pyxecm/maintenance_page/app.py +6 -2
- pyxecm/otcs.py +1947 -228
- pyxecm/otds.py +3 -11
- {pyxecm-2.0.3.dist-info → pyxecm-2.0.4.dist-info}/METADATA +1 -1
- {pyxecm-2.0.3.dist-info → pyxecm-2.0.4.dist-info}/RECORD +17 -17
- {pyxecm-2.0.3.dist-info → pyxecm-2.0.4.dist-info}/WHEEL +0 -0
- {pyxecm-2.0.3.dist-info → pyxecm-2.0.4.dist-info}/licenses/LICENSE +0 -0
- {pyxecm-2.0.3.dist-info → pyxecm-2.0.4.dist-info}/top_level.txt +0 -0
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
|
|
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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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 (
|
|
2109
|
-
# type = 0
|
|
2110
|
-
query = {
|
|
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
|
|
2118
|
-
|
|
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
|
|
2128
|
-
warning_message="Couldn't find
|
|
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
|
|
2346
|
+
def get_users_iterator(
|
|
2135
2347
|
self,
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
-
|
|
2178
|
-
|
|
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
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
2200
|
-
|
|
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
|
-
|
|
2410
|
+
Returns:
|
|
2411
|
+
iter:
|
|
2412
|
+
A generator yielding one user per iteration.
|
|
2413
|
+
If the REST API fails, returns no value.
|
|
2203
2414
|
|
|
2204
|
-
|
|
2205
|
-
self.get_user.cache_clear()
|
|
2415
|
+
"""
|
|
2206
2416
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. "
|
|
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
|
-
|
|
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
|
-
"
|
|
2665
|
-
|
|
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="
|
|
3348
|
+
method="GET",
|
|
2672
3349
|
headers=request_header,
|
|
2673
3350
|
timeout=None,
|
|
2674
|
-
failure_message="Failed to
|
|
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
|
|
2680
|
-
|
|
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
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
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
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2697
|
-
|
|
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
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
3467
|
+
|
|
3468
|
+
Example:
|
|
3469
|
+
```json
|
|
2729
3470
|
{
|
|
2730
|
-
|
|
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
|
-
|
|
2733
|
-
|
|
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
|
-
|
|
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 (
|
|
2744
|
-
# type = 1
|
|
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
|
|
2895
|
-
Instead you can iterate over the potential large list of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
9895
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
11450
|
-
|
|
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):
|
|
13634
|
-
|
|
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:
|
|
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
|
|
15666
|
+
def traverse_node(
|
|
14669
15667
|
self,
|
|
14670
|
-
|
|
14671
|
-
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
) ->
|
|
14675
|
-
"""
|
|
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
|
-
|
|
14682
|
-
The current node
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
|
|
14686
|
-
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
|
|
14690
|
-
|
|
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
|
-
|
|
14695
|
-
|
|
14696
|
-
|
|
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=
|
|
14699
|
-
description = self.get_result_value(response=
|
|
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=
|
|
15950
|
+
response=node,
|
|
14702
15951
|
key="name_multilingual",
|
|
14703
15952
|
)
|
|
14704
15953
|
descriptions_multilingual = self.get_result_value(
|
|
14705
|
-
response=
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|