pyPreservica 0.9.9__py3-none-any.whl → 3.3.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.
- pyPreservica/__init__.py +26 -8
- pyPreservica/adminAPI.py +877 -0
- pyPreservica/authorityAPI.py +229 -0
- pyPreservica/common.py +553 -94
- pyPreservica/contentAPI.py +331 -65
- pyPreservica/entityAPI.py +1805 -446
- pyPreservica/mdformsAPI.py +572 -0
- pyPreservica/monitorAPI.py +153 -0
- pyPreservica/opex.py +98 -0
- pyPreservica/parAPI.py +226 -0
- pyPreservica/retentionAPI.py +155 -44
- pyPreservica/settingsAPI.py +295 -0
- pyPreservica/uploadAPI.py +1120 -321
- pyPreservica/webHooksAPI.py +211 -0
- pyPreservica/workflowAPI.py +99 -47
- {pyPreservica-0.9.9.dist-info → pypreservica-3.3.4.dist-info}/METADATA +93 -66
- pypreservica-3.3.4.dist-info/RECORD +20 -0
- {pyPreservica-0.9.9.dist-info → pypreservica-3.3.4.dist-info}/WHEEL +5 -5
- pyPreservica-0.9.9.dist-info/RECORD +0 -12
- {pyPreservica-0.9.9.dist-info → pypreservica-3.3.4.dist-info/licenses}/LICENSE.txt +0 -0
- {pyPreservica-0.9.9.dist-info → pypreservica-3.3.4.dist-info}/top_level.txt +0 -0
pyPreservica/common.py
CHANGED
|
@@ -1,36 +1,49 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Base class for authenticated API calls used by Entity, Content and Upload
|
|
3
3
|
|
|
4
|
+
Manages the authentication token lifetime and namespace versions.
|
|
5
|
+
|
|
4
6
|
author: James Carr
|
|
5
7
|
licence: Apache License 2.0
|
|
6
8
|
|
|
7
9
|
"""
|
|
8
|
-
|
|
9
10
|
import configparser
|
|
11
|
+
import functools
|
|
10
12
|
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
11
15
|
import os
|
|
16
|
+
import platform
|
|
17
|
+
import re
|
|
12
18
|
import sys
|
|
13
19
|
import threading
|
|
14
20
|
import time
|
|
21
|
+
import unicodedata
|
|
15
22
|
import xml.etree.ElementTree
|
|
16
23
|
from enum import Enum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import pyotp
|
|
26
|
+
from requests import Session
|
|
27
|
+
from urllib3.util import Retry
|
|
17
28
|
import requests
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
29
|
+
from requests.adapters import HTTPAdapter
|
|
30
|
+
from typing import TypeVar
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
import dateutil
|
|
21
33
|
|
|
22
34
|
import pyPreservica
|
|
23
35
|
|
|
24
36
|
logger = logging.getLogger(__name__)
|
|
25
37
|
|
|
26
|
-
CHUNK_SIZE = 1024 * 2
|
|
27
|
-
|
|
28
38
|
NS_XIP_ROOT = "http://preservica.com/XIP/"
|
|
29
39
|
NS_ENTITY_ROOT = "http://preservica.com/EntityAPI/"
|
|
30
40
|
NS_RM_ROOT = "http://preservica.com/RetentionManagement/"
|
|
41
|
+
NS_SEC_ROOT = "http://preservica.com/SecurityAPI"
|
|
31
42
|
|
|
32
43
|
NS_WORKFLOW = "http://workflow.preservica.com"
|
|
33
44
|
|
|
45
|
+
NS_ADMIN = "http://preservica.com/AdminAPI"
|
|
46
|
+
|
|
34
47
|
NS_XIP_V6 = "http://preservica.com/XIP/v6.0"
|
|
35
48
|
NS_ENTITY = "http://preservica.com/EntityAPI/v6.0"
|
|
36
49
|
|
|
@@ -41,6 +54,8 @@ SO_PATH = "structural-objects"
|
|
|
41
54
|
CO_PATH = "content-objects"
|
|
42
55
|
|
|
43
56
|
HASH_BLOCK_SIZE = 65536
|
|
57
|
+
TIME_OUT = 62
|
|
58
|
+
CHUNK_SIZE = 1024 * 4
|
|
44
59
|
|
|
45
60
|
|
|
46
61
|
class FileHash:
|
|
@@ -65,8 +80,39 @@ class FileHash:
|
|
|
65
80
|
return hash_algorithm.hexdigest()
|
|
66
81
|
|
|
67
82
|
|
|
83
|
+
def identifiersToDict(identifiers: set) -> dict:
|
|
84
|
+
"""
|
|
85
|
+
Convert a set of tuples to a dict
|
|
86
|
+
:param identifiers:
|
|
87
|
+
:return:
|
|
88
|
+
"""
|
|
89
|
+
result = {}
|
|
90
|
+
for identifier_tuple in identifiers:
|
|
91
|
+
result[identifier_tuple[0]] = identifier_tuple[1]
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def strtobool(val) -> bool:
|
|
96
|
+
"""
|
|
97
|
+
Convert a string representation of truth to true (1) or false (0).
|
|
98
|
+
|
|
99
|
+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
|
100
|
+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
|
101
|
+
'val' is anything else.
|
|
102
|
+
"""
|
|
103
|
+
val = val.lower()
|
|
104
|
+
if val in ('y', 'yes', 't', 'true', 'on', '1'):
|
|
105
|
+
return True
|
|
106
|
+
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
|
|
107
|
+
return False
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError("invalid truth value %r" % (val,))
|
|
110
|
+
|
|
111
|
+
|
|
68
112
|
def _make_stored_zipfile(base_name, base_dir, owner, group, verbose=0, dry_run=0, logger=None):
|
|
69
|
-
"""
|
|
113
|
+
"""
|
|
114
|
+
Create a non compressed zip file from all the files under 'base_dir'.
|
|
115
|
+
|
|
70
116
|
|
|
71
117
|
The output zip file will be named 'base_name' + ".zip". Returns the
|
|
72
118
|
name of the output zip file.
|
|
@@ -109,21 +155,13 @@ def _make_stored_zipfile(base_name, base_dir, owner, group, verbose=0, dry_run=0
|
|
|
109
155
|
return zip_filename
|
|
110
156
|
|
|
111
157
|
|
|
112
|
-
def only_assets(entity):
|
|
113
|
-
return bool(entity.entity_type is EntityType.ASSET)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def only_folders(entity):
|
|
117
|
-
return bool(entity.entity_type is EntityType.FOLDER)
|
|
118
|
-
|
|
119
|
-
|
|
120
158
|
class PagedSet:
|
|
121
159
|
"""
|
|
122
160
|
Class to represent a page of results
|
|
123
161
|
The results object contains the list of objects of interest
|
|
124
162
|
"""
|
|
125
163
|
|
|
126
|
-
def __init__(self, results, has_more, total, next_page):
|
|
164
|
+
def __init__(self, results, has_more: bool, total: int, next_page: str):
|
|
127
165
|
self.results = results
|
|
128
166
|
self.has_more = bool(has_more)
|
|
129
167
|
self.total = int(total)
|
|
@@ -160,12 +198,83 @@ class Sha512FixityCallBack:
|
|
|
160
198
|
return "SHA512", sha(full_path)
|
|
161
199
|
|
|
162
200
|
|
|
201
|
+
class ReportProgressConsoleCallback:
|
|
202
|
+
|
|
203
|
+
def __init__(self, prefix='Progress:', suffix='', length=100, fill='█', printEnd="\r"):
|
|
204
|
+
self.prefix = prefix
|
|
205
|
+
self.suffix = suffix
|
|
206
|
+
self.length = length
|
|
207
|
+
self.fill = fill
|
|
208
|
+
self.printEnd = printEnd
|
|
209
|
+
self._lock = threading.Lock()
|
|
210
|
+
self.print_progress_bar(0)
|
|
211
|
+
|
|
212
|
+
def __call__(self, value):
|
|
213
|
+
with self._lock:
|
|
214
|
+
values = value.split(":")
|
|
215
|
+
self.total = int(values[1])
|
|
216
|
+
self.current = int(values[0])
|
|
217
|
+
if self.total == 0:
|
|
218
|
+
percentage = 100.0
|
|
219
|
+
else:
|
|
220
|
+
percentage = (self.current / self.total) * 100
|
|
221
|
+
self.print_progress_bar(percentage)
|
|
222
|
+
if int(percentage) == int(100):
|
|
223
|
+
self.print_progress_bar(100.0)
|
|
224
|
+
sys.stdout.write(self.printEnd)
|
|
225
|
+
sys.stdout.flush()
|
|
226
|
+
|
|
227
|
+
def print_progress_bar(self, percentage):
|
|
228
|
+
filled_length = int(self.length * (percentage / 100.0))
|
|
229
|
+
bar_sym = self.fill * filled_length + '-' * (self.length - filled_length)
|
|
230
|
+
sys.stdout.write(
|
|
231
|
+
'\r%s |%s| (%.2f%%) %s ' % (self.prefix, bar_sym, percentage, self.suffix))
|
|
232
|
+
sys.stdout.flush()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class UploadProgressConsoleCallback:
|
|
236
|
+
|
|
237
|
+
def __init__(self, filename: str, prefix='Progress:', suffix='', length=100, fill='█', printEnd="\r"):
|
|
238
|
+
self.prefix = prefix
|
|
239
|
+
self.suffix = suffix
|
|
240
|
+
self.length = length
|
|
241
|
+
self.fill = fill
|
|
242
|
+
self.printEnd = printEnd
|
|
243
|
+
self._filename = filename
|
|
244
|
+
self._size = float(Path(filename).stat().st_size)
|
|
245
|
+
self._seen_so_far = 0
|
|
246
|
+
self.start = time.time()
|
|
247
|
+
self._lock = threading.Lock()
|
|
248
|
+
self.print_progress_bar(0, 0)
|
|
249
|
+
|
|
250
|
+
def __call__(self, bytes_amount):
|
|
251
|
+
with self._lock:
|
|
252
|
+
seconds = time.time() - self.start
|
|
253
|
+
if seconds == 0:
|
|
254
|
+
seconds = 1.0
|
|
255
|
+
self._seen_so_far += bytes_amount
|
|
256
|
+
percentage = (self._seen_so_far / self._size) * float(100.0)
|
|
257
|
+
rate = (self._seen_so_far / (1024 * 1024)) / seconds
|
|
258
|
+
self.print_progress_bar(percentage, rate)
|
|
259
|
+
if int(self._seen_so_far) == int(self._size):
|
|
260
|
+
self.print_progress_bar(100.0, rate)
|
|
261
|
+
sys.stdout.write(self.printEnd)
|
|
262
|
+
sys.stdout.flush()
|
|
263
|
+
|
|
264
|
+
def print_progress_bar(self, percentage, rate):
|
|
265
|
+
filled_length = int(self.length * (percentage / 100.0))
|
|
266
|
+
bar_sym = self.fill * filled_length + '-' * (self.length - filled_length)
|
|
267
|
+
sys.stdout.write(
|
|
268
|
+
'\r%s |%s| (%.2f%%) (%.2f %s) %s ' % (self.prefix, bar_sym, percentage, rate, "Mb/s", self.suffix))
|
|
269
|
+
sys.stdout.flush()
|
|
270
|
+
|
|
271
|
+
|
|
163
272
|
class UploadProgressCallback:
|
|
164
273
|
"""
|
|
165
274
|
Default implementation of a callback class to show upload progress of a file
|
|
166
275
|
"""
|
|
167
276
|
|
|
168
|
-
def __init__(self, filename):
|
|
277
|
+
def __init__(self, filename: str):
|
|
169
278
|
self._filename = filename
|
|
170
279
|
self._size = float(os.path.getsize(filename))
|
|
171
280
|
self._seen_so_far = 0
|
|
@@ -179,6 +288,87 @@ class UploadProgressCallback:
|
|
|
179
288
|
sys.stdout.flush()
|
|
180
289
|
|
|
181
290
|
|
|
291
|
+
class RelationshipDirection(Enum):
|
|
292
|
+
FROM = "From"
|
|
293
|
+
TO = "To"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class EntityType(Enum):
|
|
297
|
+
"""
|
|
298
|
+
Enumeration of the Entity Types
|
|
299
|
+
"""
|
|
300
|
+
ASSET = "IO"
|
|
301
|
+
FOLDER = "SO"
|
|
302
|
+
CONTENT_OBJECT = "CO"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class HTTPException(Exception):
|
|
306
|
+
"""
|
|
307
|
+
Custom Exception non 404 errors
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(self, reference, http_status_code, url, method_name, message):
|
|
311
|
+
self.reference = reference
|
|
312
|
+
self.url = url
|
|
313
|
+
self.method_name = method_name
|
|
314
|
+
self.http_status_code = http_status_code
|
|
315
|
+
self.msg = message
|
|
316
|
+
Exception.__init__(self, self.reference, self.http_status_code, self.url, self.msg)
|
|
317
|
+
|
|
318
|
+
def __str__(self):
|
|
319
|
+
return f"Calling method {self.method_name}() {self.url} returned HTTP {self.http_status_code}. {self.msg}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class ReferenceNotFoundException(Exception):
|
|
323
|
+
"""
|
|
324
|
+
Custom Exception for failed lookups by reference 404 Errors
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, reference, http_status_code, url, method_name):
|
|
328
|
+
self.reference = reference
|
|
329
|
+
self.url = url
|
|
330
|
+
self.method_name = method_name
|
|
331
|
+
self.http_status_code = http_status_code
|
|
332
|
+
self.msg = f"The requested reference {self.reference} is not found in the repository"
|
|
333
|
+
Exception.__init__(self, self.reference, self.http_status_code, self.url, self.msg)
|
|
334
|
+
|
|
335
|
+
def __str__(self):
|
|
336
|
+
return f"Calling method {self.method_name}() {self.url} returned HTTP {self.http_status_code}. {self.msg}"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class Relationship:
|
|
340
|
+
DCMI_hasFormat = "http://purl.org/dc/terms/hasFormat"
|
|
341
|
+
DCMI_isFormatOf = "http://purl.org/dc/terms/isFormatOf"
|
|
342
|
+
DCMI_hasPart = "http://purl.org/dc/terms/hasPart"
|
|
343
|
+
DCMI_isPartOf = "http://purl.org/dc/terms/isPartOf"
|
|
344
|
+
DCMI_hasVersion = "http://purl.org/dc/terms/hasVersion"
|
|
345
|
+
DCMI_isVersionOf = "http://purl.org/dc/terms/isVersionOf"
|
|
346
|
+
DCMI_isReferencedBy = "http://purl.org/dc/terms/isReferencedBy"
|
|
347
|
+
DCMI_references = "http://purl.org/dc/terms/references"
|
|
348
|
+
DCMI_isReplacedBy = "http://purl.org/dc/terms/isReplacedBy"
|
|
349
|
+
DCMI_replaces = "http://purl.org/dc/terms/replaces"
|
|
350
|
+
DCMI_isRequiredBy = "http://purl.org/dc/terms/isRequiredBy"
|
|
351
|
+
DCMI_requires = "http://purl.org/dc/terms/requires"
|
|
352
|
+
DCMI_conformsTo = "http://purl.org/dc/terms/conformsTo"
|
|
353
|
+
|
|
354
|
+
def __init__(self, relationship_id: str, relationship_type: str, direction: RelationshipDirection, other_ref: str,
|
|
355
|
+
title: str, entity_type: EntityType, this_ref: str, api_id: str):
|
|
356
|
+
self.api_id = api_id
|
|
357
|
+
self.this_ref = this_ref
|
|
358
|
+
self.entity_type = entity_type
|
|
359
|
+
self.title = title
|
|
360
|
+
self.other_ref = other_ref
|
|
361
|
+
self.direction = direction
|
|
362
|
+
self.relationship_type = relationship_type
|
|
363
|
+
self.relationship_id = relationship_id
|
|
364
|
+
|
|
365
|
+
def __str__(self):
|
|
366
|
+
if self.direction == RelationshipDirection.FROM:
|
|
367
|
+
return f"{self.this_ref} {self.relationship_type} {self.other_ref}"
|
|
368
|
+
else:
|
|
369
|
+
return f"{self.other_ref} {self.relationship_type} {self.this_ref}"
|
|
370
|
+
|
|
371
|
+
|
|
182
372
|
class IntegrityCheck:
|
|
183
373
|
"""
|
|
184
374
|
Class to hold information about completed integrity checks
|
|
@@ -208,64 +398,75 @@ class IntegrityCheck:
|
|
|
208
398
|
return self.success
|
|
209
399
|
|
|
210
400
|
|
|
211
|
-
class
|
|
401
|
+
class Bitstream:
|
|
212
402
|
"""
|
|
213
|
-
Class to represent the
|
|
403
|
+
Class to represent the Bitstream Object or digital file in the Preservica data model
|
|
214
404
|
"""
|
|
215
405
|
|
|
216
|
-
def __init__(self,
|
|
217
|
-
self.
|
|
218
|
-
self.
|
|
219
|
-
self.
|
|
220
|
-
self.
|
|
406
|
+
def __init__(self, filename: str, length: int, fixity: dict, content_url: str):
|
|
407
|
+
self.filename = filename
|
|
408
|
+
self.length = int(length)
|
|
409
|
+
self.fixity = fixity
|
|
410
|
+
self.content_url = content_url
|
|
411
|
+
self.bs_index = None
|
|
412
|
+
self.gen_index = None
|
|
413
|
+
self.co_ref = None
|
|
221
414
|
|
|
222
415
|
def __str__(self):
|
|
223
|
-
return f"
|
|
224
|
-
|
|
225
|
-
|
|
416
|
+
return f"""
|
|
417
|
+
Filename: {self.filename}
|
|
418
|
+
File Length: {self.length}
|
|
419
|
+
Fixity: {self.fixity}
|
|
420
|
+
"""
|
|
226
421
|
|
|
227
422
|
def __repr__(self):
|
|
228
423
|
return self.__str__()
|
|
229
424
|
|
|
230
425
|
|
|
231
|
-
class
|
|
426
|
+
class ExternIdentifier:
|
|
232
427
|
"""
|
|
233
|
-
Class to represent the
|
|
428
|
+
Class to represent the External Identifier Object in the Preservica data model
|
|
234
429
|
"""
|
|
235
430
|
|
|
236
|
-
def __init__(self,
|
|
237
|
-
self.
|
|
238
|
-
self.
|
|
239
|
-
self.
|
|
240
|
-
self.content_url = content_url
|
|
431
|
+
def __init__(self, identifier_type: str, identifier_value: str):
|
|
432
|
+
self.type = identifier_type
|
|
433
|
+
self.value = identifier_value
|
|
434
|
+
self.id = None
|
|
241
435
|
|
|
242
436
|
def __str__(self):
|
|
243
|
-
return f"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
437
|
+
return f"""
|
|
438
|
+
Identifier: {self.id}
|
|
439
|
+
Identifier Type: {self.type}
|
|
440
|
+
Identifier Value: {self.value}
|
|
441
|
+
"""
|
|
247
442
|
|
|
248
443
|
def __repr__(self):
|
|
249
444
|
return self.__str__()
|
|
250
445
|
|
|
251
|
-
|
|
252
446
|
class Generation:
|
|
253
447
|
"""
|
|
254
448
|
Class to represent the Generation Object in the Preservica data model
|
|
255
|
-
|
|
449
|
+
"""
|
|
256
450
|
|
|
257
|
-
def __init__(self, original, active, format_group, effective_date, bitstreams):
|
|
258
|
-
self.original = original
|
|
259
|
-
self.active = active
|
|
451
|
+
def __init__(self, original: bool, active: bool, format_group: str, effective_date: str, bitstreams: list):
|
|
452
|
+
self.original = bool(original)
|
|
453
|
+
self.active = bool(active)
|
|
260
454
|
self.content_object = None
|
|
261
455
|
self.format_group = format_group
|
|
262
456
|
self.effective_date = effective_date
|
|
263
457
|
self.bitstreams = bitstreams
|
|
458
|
+
self.properties = list()
|
|
459
|
+
self.formats = list()
|
|
264
460
|
|
|
265
461
|
def __str__(self):
|
|
266
|
-
return f"
|
|
267
|
-
|
|
268
|
-
|
|
462
|
+
return f"""
|
|
463
|
+
Active: {self.active}
|
|
464
|
+
Original: {self.original}
|
|
465
|
+
Format Group: {self.format_group}
|
|
466
|
+
Effective Date: {self.effective_date}
|
|
467
|
+
Formats: {self.formats}
|
|
468
|
+
Properties: {self.properties}
|
|
469
|
+
"""
|
|
269
470
|
|
|
270
471
|
def __repr__(self):
|
|
271
472
|
return self.__str__()
|
|
@@ -276,7 +477,7 @@ class Entity:
|
|
|
276
477
|
Base Class of Assets, Folders and Content Objects
|
|
277
478
|
"""
|
|
278
479
|
|
|
279
|
-
def __init__(self, reference, title, description, security_tag, parent, metadata):
|
|
480
|
+
def __init__(self, reference: str, title: str, description: str, security_tag: str, parent: str, metadata: dict):
|
|
280
481
|
self.reference = reference
|
|
281
482
|
self.title = title
|
|
282
483
|
self.description = description
|
|
@@ -286,24 +487,36 @@ class Entity:
|
|
|
286
487
|
self.entity_type = None
|
|
287
488
|
self.path = None
|
|
288
489
|
self.tag = None
|
|
490
|
+
self.custom_type = None
|
|
289
491
|
|
|
290
492
|
def __str__(self):
|
|
291
|
-
return f"
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
493
|
+
return f"""
|
|
494
|
+
Entity: {self.entity_type}
|
|
495
|
+
Entity Ref: {self.reference}
|
|
496
|
+
Title: {self.title}
|
|
497
|
+
Description: {self.description}
|
|
498
|
+
Security Tag: {self.security_tag}
|
|
499
|
+
Parent: {self.parent}
|
|
500
|
+
Custom Type: {self.custom_type}
|
|
501
|
+
"""
|
|
296
502
|
|
|
297
503
|
def __repr__(self):
|
|
298
504
|
return self.__str__()
|
|
299
505
|
|
|
506
|
+
def has_metadata(self) -> bool:
|
|
507
|
+
return bool(self.metadata)
|
|
508
|
+
|
|
509
|
+
def metadata_namespaces(self) -> list:
|
|
510
|
+
return list(self.metadata.values())
|
|
511
|
+
|
|
300
512
|
|
|
301
513
|
class Folder(Entity):
|
|
302
514
|
"""
|
|
303
515
|
Class to represent the Structural Object or Folder in the Preservica data model
|
|
304
516
|
"""
|
|
305
517
|
|
|
306
|
-
def __init__(self, reference, title, description, security_tag
|
|
518
|
+
def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
|
|
519
|
+
parent: str = None, metadata: dict = None):
|
|
307
520
|
super().__init__(reference, title, description, security_tag, parent, metadata)
|
|
308
521
|
self.entity_type = EntityType.FOLDER
|
|
309
522
|
self.path = SO_PATH
|
|
@@ -315,7 +528,8 @@ class Asset(Entity):
|
|
|
315
528
|
Class to represent the Information Object or Asset in the Preservica data model
|
|
316
529
|
"""
|
|
317
530
|
|
|
318
|
-
def __init__(self, reference, title, description, security_tag
|
|
531
|
+
def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
|
|
532
|
+
parent: str = None, metadata: dict = None):
|
|
319
533
|
super().__init__(reference, title, description, security_tag, parent, metadata)
|
|
320
534
|
self.entity_type = EntityType.ASSET
|
|
321
535
|
self.path = IO_PATH
|
|
@@ -327,7 +541,8 @@ class ContentObject(Entity):
|
|
|
327
541
|
Class to represent the Content Object in the Preservica data model
|
|
328
542
|
"""
|
|
329
543
|
|
|
330
|
-
def __init__(self, reference, title, description, security_tag
|
|
544
|
+
def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
|
|
545
|
+
parent: str = None, metadata: dict = None):
|
|
331
546
|
super().__init__(reference, title, description, security_tag, parent, metadata)
|
|
332
547
|
self.entity_type = EntityType.CONTENT_OBJECT
|
|
333
548
|
self.representation_type = None
|
|
@@ -336,7 +551,38 @@ class ContentObject(Entity):
|
|
|
336
551
|
self.tag = "ContentObject"
|
|
337
552
|
|
|
338
553
|
|
|
339
|
-
|
|
554
|
+
EntityT = TypeVar("EntityT", Folder, Asset, ContentObject, None)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class Representation:
|
|
558
|
+
"""
|
|
559
|
+
Class to represent the Representation Object in the Preservica data model
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
def __init__(self, asset: Asset, rep_type: str, name: str, url: str):
|
|
563
|
+
self.asset = asset
|
|
564
|
+
self.rep_type = rep_type
|
|
565
|
+
self.name = name
|
|
566
|
+
self.url = url
|
|
567
|
+
|
|
568
|
+
def __str__(self):
|
|
569
|
+
return f"Type:\t\t\t{self.rep_type}\n" \
|
|
570
|
+
f"Name:\t\t\t{self.name}\n" \
|
|
571
|
+
f"URL:\t{self.url}"
|
|
572
|
+
|
|
573
|
+
def __repr__(self):
|
|
574
|
+
return self.__str__()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def only_assets(entity: Entity):
|
|
578
|
+
return bool(entity.entity_type is EntityType.ASSET)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def only_folders(entity: Entity):
|
|
582
|
+
return bool(entity.entity_type is EntityType.FOLDER)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def content_api_identifier_to_type(ref: str):
|
|
340
586
|
ref = ref.replace('sdb:', '')
|
|
341
587
|
parts = ref.split("|")
|
|
342
588
|
return tuple((EntityType(parts[0]), parts[1]))
|
|
@@ -348,19 +594,30 @@ class Thumbnail(Enum):
|
|
|
348
594
|
LARGE = "large"
|
|
349
595
|
|
|
350
596
|
|
|
351
|
-
class
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
""
|
|
597
|
+
class AsyncProgress(Enum):
|
|
598
|
+
"""
|
|
599
|
+
Enumeration of the possible status of an asynchronous process
|
|
600
|
+
"""
|
|
601
|
+
ABORTED = "ABORTED"
|
|
602
|
+
ACTIVE = "ACTIVE"
|
|
603
|
+
COMPLETED = "COMPLETED"
|
|
604
|
+
PENDING = "PENDING"
|
|
605
|
+
SUSPENDING = "SUSPENDING"
|
|
606
|
+
SUSPENDED = "SUSPENDED"
|
|
607
|
+
UNKNOWN = "UNKNOWN"
|
|
608
|
+
FAILED = "FAILED"
|
|
609
|
+
FINISHED_MIXED_OUTCOME = "FINISHED_MIXED_OUTCOME"
|
|
610
|
+
CANCELLED = "CANCELLED"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def sanitize(filename) -> str:
|
|
614
|
+
"""
|
|
615
|
+
Return a fairly safe version of the filename.
|
|
359
616
|
|
|
360
617
|
We don't limit ourselves to ascii, because we want to keep municipality
|
|
361
|
-
names, etc
|
|
618
|
+
names, etc., but we do want to get rid of anything potentially harmful,
|
|
362
619
|
and make sure we do not exceed Windows filename length limits.
|
|
363
|
-
Hence a less safe blacklist, rather than a whitelist.
|
|
620
|
+
Hence, a less safe blacklist, rather than a whitelist.
|
|
364
621
|
"""
|
|
365
622
|
blacklist = ["\\", "/", ":", "*", "?", "\"", "<", ">", "|", "\0"]
|
|
366
623
|
reserved = [
|
|
@@ -403,16 +660,91 @@ def sanitize(filename):
|
|
|
403
660
|
|
|
404
661
|
class AuthenticatedAPI:
|
|
405
662
|
"""
|
|
406
|
-
|
|
663
|
+
Base class for authenticated calls which need an access token
|
|
664
|
+
Authenticated calls include a "Preservica-Access-Token" header in the request
|
|
407
665
|
"""
|
|
408
666
|
|
|
409
|
-
def
|
|
667
|
+
def _check_if_user_has_manager_role(self):
|
|
668
|
+
"""
|
|
669
|
+
Check if the current user has a least a manager role
|
|
670
|
+
:return: None
|
|
671
|
+
|
|
672
|
+
Throws RuntimeError if the user does not have required roles
|
|
673
|
+
"""
|
|
674
|
+
if ('ROLE_SDB_MANAGER_USER' not in self.roles) and ('ROLE_SDB_ADMIN_USER' not in self.roles):
|
|
675
|
+
logger.error(f"The AdminAPI requires the user to have ROLE_SDB_MANAGER_USER")
|
|
676
|
+
raise RuntimeError(f"The API requires the user to have at least the ROLE_SDB_MANAGER_USER")
|
|
677
|
+
|
|
678
|
+
def _find_user_roles_(self) -> list[str]:
|
|
679
|
+
"""
|
|
680
|
+
Get a list of roles for the user
|
|
681
|
+
:return list of roles:
|
|
682
|
+
"""
|
|
683
|
+
headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json'}
|
|
684
|
+
request = self.session.get(f"{self.protocol}://{self.server}/api/user/details", headers=headers)
|
|
685
|
+
logger.debug(request.headers)
|
|
686
|
+
if request.status_code == requests.codes.ok:
|
|
687
|
+
json_document = str(request.content.decode('utf-8'))
|
|
688
|
+
logger.debug(json_document)
|
|
689
|
+
roles: list[str] = json.loads(json_document)['roles']
|
|
690
|
+
return roles
|
|
691
|
+
elif request.status_code == requests.codes.unauthorized:
|
|
692
|
+
self.token = self.__token__()
|
|
693
|
+
return self._find_user_roles_()
|
|
694
|
+
return []
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def security_tags_base(self, with_permissions: bool = False) -> dict:
|
|
698
|
+
"""
|
|
699
|
+
Return security tags available for the current user
|
|
700
|
+
|
|
701
|
+
:return: dict of security tags
|
|
702
|
+
:rtype: dict
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
if (self.major_version < 7) and (self.minor_version < 4) and (self.patch_version < 1):
|
|
706
|
+
raise RuntimeError("security_tags API call is only available with a Preservica v6.3.1 system or higher")
|
|
707
|
+
|
|
708
|
+
headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
|
|
709
|
+
|
|
710
|
+
request = self.session.get(f'{self.protocol}://{self.server}/api/security/tags', headers=headers)
|
|
711
|
+
if request.status_code == requests.codes.ok:
|
|
712
|
+
xml_response = str(request.content.decode('utf-8'))
|
|
713
|
+
logger.debug(xml_response)
|
|
714
|
+
entity_response = xml.etree.ElementTree.fromstring(xml_response)
|
|
715
|
+
security_tags = {}
|
|
716
|
+
tags = entity_response.findall(f'.//{{{self.sec_ns}}}Tag')
|
|
717
|
+
for tag in tags:
|
|
718
|
+
if with_permissions:
|
|
719
|
+
permissions = []
|
|
720
|
+
for p in tag.findall(f'.//{{{self.sec_ns}}}Permission'):
|
|
721
|
+
permissions.append(p.text)
|
|
722
|
+
security_tags[tag.attrib['name']] = permissions
|
|
723
|
+
else:
|
|
724
|
+
security_tags[tag.attrib['name']] = tag.attrib['name']
|
|
725
|
+
return security_tags
|
|
726
|
+
if request.status_code == requests.codes.unauthorized:
|
|
727
|
+
self.token = self.__token__()
|
|
728
|
+
return self.security_tags_base()
|
|
729
|
+
else:
|
|
730
|
+
logger.error(f'security_tags failed {request.status_code}')
|
|
731
|
+
raise RuntimeError(request.status_code, "security_tags failed")
|
|
732
|
+
|
|
733
|
+
def entity_from_string(self, xml_data: str) -> dict:
|
|
734
|
+
"""
|
|
735
|
+
Create a basic entity from XML data
|
|
736
|
+
|
|
737
|
+
:param xml_data:
|
|
738
|
+
:return: dict
|
|
739
|
+
"""
|
|
410
740
|
entity_response = xml.etree.ElementTree.fromstring(xml_data)
|
|
411
741
|
reference = entity_response.find(f'.//{{{self.xip_ns}}}Ref')
|
|
412
742
|
title = entity_response.find(f'.//{{{self.xip_ns}}}Title')
|
|
413
743
|
security_tag = entity_response.find(f'.//{{{self.xip_ns}}}SecurityTag')
|
|
414
744
|
description = entity_response.find(f'.//{{{self.xip_ns}}}Description')
|
|
415
745
|
parent = entity_response.find(f'.//{{{self.xip_ns}}}Parent')
|
|
746
|
+
custom_type = entity_response.find(f'.//{{{self.xip_ns}}}CustomType')
|
|
747
|
+
|
|
416
748
|
if hasattr(parent, 'text'):
|
|
417
749
|
parent = parent.text
|
|
418
750
|
else:
|
|
@@ -423,14 +755,48 @@ class AuthenticatedAPI:
|
|
|
423
755
|
for fragment in fragments:
|
|
424
756
|
metadata[fragment.text] = fragment.attrib['schema']
|
|
425
757
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
758
|
+
entity_dict = {'reference': reference.text, 'title': title.text if hasattr(title, 'text') else None,
|
|
759
|
+
'description': description.text if hasattr(description, 'text') else None,
|
|
760
|
+
'security_tag': security_tag.text, 'parent': parent, 'metadata': metadata}
|
|
761
|
+
|
|
762
|
+
if hasattr(custom_type, 'text'):
|
|
763
|
+
entity_dict['CustomType'] = custom_type.text
|
|
764
|
+
|
|
765
|
+
return entity_dict
|
|
766
|
+
|
|
767
|
+
def edition(self) -> str:
|
|
768
|
+
"""
|
|
769
|
+
Return the edition of this tenancy
|
|
770
|
+
"""
|
|
771
|
+
if self.major_version < 8 and self.minor_version < 3:
|
|
772
|
+
raise RuntimeError("Entitlement API is only available when connected to a v7.3 System")
|
|
773
|
+
|
|
774
|
+
headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json'}
|
|
775
|
+
|
|
776
|
+
response = self.session.get(f'{self.protocol}://{self.server}/api/entitlement/edition', headers=headers)
|
|
777
|
+
|
|
778
|
+
if response.status_code == requests.codes.ok:
|
|
779
|
+
return response.json()['edition']
|
|
780
|
+
elif response.status_code == requests.codes.unauthorized:
|
|
781
|
+
self.token = self.__token__()
|
|
782
|
+
return self.edition()
|
|
783
|
+
else:
|
|
784
|
+
exception = HTTPException("", response.status_code, response.url,
|
|
785
|
+
"edition", response.content.decode('utf-8'))
|
|
786
|
+
logger.error(exception)
|
|
787
|
+
raise exception
|
|
429
788
|
|
|
430
789
|
def __version_namespace__(self):
|
|
431
790
|
"""
|
|
432
791
|
Generate version specific namespaces from the server version
|
|
433
792
|
"""
|
|
793
|
+
if self.major_version > 6:
|
|
794
|
+
self.xip_ns = f"{NS_XIP_ROOT}v{self.major_version}.{self.minor_version}"
|
|
795
|
+
self.entity_ns = f"{NS_ENTITY_ROOT}v{self.major_version}.{self.minor_version}"
|
|
796
|
+
self.rm_ns = f"{NS_RM_ROOT}v{6}.{2}"
|
|
797
|
+
self.sec_ns = f"{NS_SEC_ROOT}/v{self.major_version}.{self.minor_version}"
|
|
798
|
+
self.admin_ns = f"{NS_ADMIN}/v{self.major_version}.{self.minor_version}"
|
|
799
|
+
|
|
434
800
|
if self.major_version == 6:
|
|
435
801
|
if self.minor_version < 2:
|
|
436
802
|
self.xip_ns = NS_XIP_V6
|
|
@@ -439,13 +805,18 @@ class AuthenticatedAPI:
|
|
|
439
805
|
self.xip_ns = f"{NS_XIP_ROOT}v{self.major_version}.{self.minor_version}"
|
|
440
806
|
self.entity_ns = f"{NS_ENTITY_ROOT}v{self.major_version}.{self.minor_version}"
|
|
441
807
|
self.rm_ns = f"{NS_RM_ROOT}v{self.major_version}.{2}"
|
|
808
|
+
self.sec_ns = f"{NS_SEC_ROOT}/v{self.major_version}.{self.minor_version}"
|
|
809
|
+
self.admin_ns = f"{NS_ADMIN}/v{self.major_version}.{self.minor_version}"
|
|
810
|
+
|
|
811
|
+
xml.etree.ElementTree.register_namespace("xip", f"{self.xip_ns}")
|
|
442
812
|
|
|
443
813
|
def __version_number__(self):
|
|
444
814
|
"""
|
|
445
815
|
Determine the version number of the server
|
|
446
816
|
"""
|
|
447
817
|
headers = {HEADER_TOKEN: self.token}
|
|
448
|
-
request = self.session.get(f'
|
|
818
|
+
request = self.session.get(f'{self.protocol}://{self.server}/api/entity/versiondetails/version',
|
|
819
|
+
headers=headers)
|
|
449
820
|
if request.status_code == requests.codes.ok:
|
|
450
821
|
xml_ = str(request.content.decode('utf-8'))
|
|
451
822
|
version = xml_[xml_.find("<CurrentVersion>") + len("<CurrentVersion>"):xml_.find("</CurrentVersion>")]
|
|
@@ -453,6 +824,7 @@ class AuthenticatedAPI:
|
|
|
453
824
|
self.major_version = int(version_numbers[0])
|
|
454
825
|
self.minor_version = int(version_numbers[1])
|
|
455
826
|
self.patch_version = int(version_numbers[2])
|
|
827
|
+
|
|
456
828
|
return version
|
|
457
829
|
elif request.status_code == requests.codes.unauthorized:
|
|
458
830
|
self.token = self.__token__()
|
|
@@ -461,24 +833,31 @@ class AuthenticatedAPI:
|
|
|
461
833
|
logger.error(f"version number failed with http response {request.status_code}")
|
|
462
834
|
logger.error(str(request.content))
|
|
463
835
|
RuntimeError(request.status_code, "version number failed")
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
|
|
464
839
|
|
|
465
840
|
def __str__(self):
|
|
466
|
-
return f"pyPreservica version: {pyPreservica.__version__} (Preservica
|
|
467
|
-
f"Connected to: {self.server} Preservica version: {self.version} as {self.username}"
|
|
841
|
+
return f"pyPreservica version: {pyPreservica.__version__} (Preservica 8.0 Compatible) " \
|
|
842
|
+
f"Connected to: {self.server} Preservica version: {self.version} as {self.username} " \
|
|
843
|
+
f"in tenancy {self.tenant}"
|
|
468
844
|
|
|
469
845
|
def __repr__(self):
|
|
470
846
|
return self.__str__()
|
|
471
847
|
|
|
472
848
|
def save_config(self):
|
|
473
|
-
config = configparser.
|
|
849
|
+
config = configparser.RawConfigParser(interpolation=None)
|
|
474
850
|
config['credentials'] = {'username': self.username, 'password': self.password, 'tenant': self.tenant,
|
|
475
851
|
'server': self.server}
|
|
476
|
-
|
|
852
|
+
if self.two_fa_secret_key is not None:
|
|
853
|
+
config['credentials']['twoFactorToken'] = self.two_fa_secret_key
|
|
854
|
+
|
|
855
|
+
with open('credentials.properties', 'wt', encoding="utf-8") as configfile:
|
|
477
856
|
config.write(configfile)
|
|
478
857
|
|
|
479
|
-
def manager_token(self, username, password):
|
|
858
|
+
def manager_token(self, username: str, password: str) -> str:
|
|
480
859
|
data = {'username': username, 'password': password, 'tenant': self.tenant}
|
|
481
|
-
response = self.session.post(f'
|
|
860
|
+
response = self.session.post(f'{self.protocol}://{self.server}/api/accesstoken/login', data=data)
|
|
482
861
|
if response.status_code == requests.codes.ok:
|
|
483
862
|
return response.json()['token']
|
|
484
863
|
else:
|
|
@@ -486,19 +865,58 @@ class AuthenticatedAPI:
|
|
|
486
865
|
logger.error(msg)
|
|
487
866
|
logger.error(response.status_code)
|
|
488
867
|
logger.error(str(response.content))
|
|
489
|
-
RuntimeError(response.status_code, "Could not generate valid manager approval
|
|
868
|
+
RuntimeError(response.status_code, "Could not generate valid manager approval token")
|
|
490
869
|
|
|
491
|
-
def __token__(self):
|
|
870
|
+
def __token__(self) -> str:
|
|
871
|
+
"""
|
|
872
|
+
Generate am API token to use to authenticate calls
|
|
873
|
+
:return: API Token
|
|
874
|
+
"""
|
|
492
875
|
logger.debug("Token Expired Requesting New Token")
|
|
493
876
|
if self.shared_secret is False:
|
|
494
|
-
if self.tenant
|
|
495
|
-
data = {'username': self.username, 'password': self.password}
|
|
877
|
+
if self.tenant is None:
|
|
878
|
+
data = {'username': self.username, 'password': self.password, 'includeUserDetails': 'true'}
|
|
496
879
|
else:
|
|
497
880
|
data = {'username': self.username, 'password': self.password, 'tenant': self.tenant}
|
|
498
|
-
response = self.session.post(f'
|
|
881
|
+
response = self.session.post(f'{self.protocol}://{self.server}/api/accesstoken/login', data=data)
|
|
499
882
|
if response.status_code == requests.codes.ok:
|
|
883
|
+
if self.tenant is None:
|
|
884
|
+
self.tenant = response.json()['tenant']
|
|
500
885
|
return response.json()['token']
|
|
501
886
|
else:
|
|
887
|
+
if 'message' in response.json():
|
|
888
|
+
if response.json()['message'] == "needs.2fa":
|
|
889
|
+
logger.debug("2FA Found")
|
|
890
|
+
if self.tenant is None:
|
|
891
|
+
self.tenant = response.json()['tenant']
|
|
892
|
+
if self.two_fa_secret_key:
|
|
893
|
+
logger.debug("Found Two Factor Token")
|
|
894
|
+
totp = pyotp.TOTP(self.two_fa_secret_key)
|
|
895
|
+
data = {'username': self.username,
|
|
896
|
+
'continuationToken': response.json()['continuationToken'],
|
|
897
|
+
'tenant': self.tenant, 'twoFactorToken': totp.now()}
|
|
898
|
+
|
|
899
|
+
header = {'Content-Type': 'application/x-www-form-urlencoded'}
|
|
900
|
+
response_2fa = self.session.post(
|
|
901
|
+
f'{self.protocol}://{self.server}/api/accesstoken/complete-2fa',
|
|
902
|
+
data=data, headers=header)
|
|
903
|
+
if response_2fa.status_code == requests.codes.ok:
|
|
904
|
+
return response_2fa.json()['token']
|
|
905
|
+
else:
|
|
906
|
+
msg = "Failed to create a 2FA authentication token. Check your credentials are correct"
|
|
907
|
+
logger.error(msg)
|
|
908
|
+
logger.error(str(response_2fa.content))
|
|
909
|
+
raise RuntimeError(response_2fa.status_code, msg)
|
|
910
|
+
else:
|
|
911
|
+
msg = "2FA twoFactorToken required to authenticate against this account using 2FA"
|
|
912
|
+
logger.error(msg)
|
|
913
|
+
logger.error(str(response.content))
|
|
914
|
+
raise RuntimeError(response.status_code, msg)
|
|
915
|
+
if response.json()['message'] == "needs.2fa.setup":
|
|
916
|
+
msg = "2FA is activated but not yet set up"
|
|
917
|
+
logger.error(msg)
|
|
918
|
+
logger.error(str(response.content))
|
|
919
|
+
raise RuntimeError(response.status_code, msg)
|
|
502
920
|
msg = "Failed to create a password based authentication token. Check your credentials are correct"
|
|
503
921
|
logger.error(msg)
|
|
504
922
|
logger.error(str(response.content))
|
|
@@ -511,7 +929,7 @@ class AuthenticatedAPI:
|
|
|
511
929
|
sha1 = hashlib.sha1()
|
|
512
930
|
sha1.update(to_hash.encode(encoding='utf-8'))
|
|
513
931
|
data = {"username": self.username, "tenant": self.tenant, "timestamp": timestamp, "hash": sha1.hexdigest()}
|
|
514
|
-
response = self.session.post(f'
|
|
932
|
+
response = self.session.post(f'{self.protocol}://{self.server}/{endpoint}', data=data)
|
|
515
933
|
if response.status_code == requests.codes.ok:
|
|
516
934
|
return response.json()['token']
|
|
517
935
|
else:
|
|
@@ -519,12 +937,40 @@ class AuthenticatedAPI:
|
|
|
519
937
|
logger.error(msg)
|
|
520
938
|
raise RuntimeError(response.status_code, msg)
|
|
521
939
|
|
|
522
|
-
def __init__(self, username=None, password=None, tenant=None, server=None,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
940
|
+
def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
|
|
941
|
+
use_shared_secret: bool = False, two_fa_secret_key: str = None,
|
|
942
|
+
protocol: str = "https", request_hook=None, credentials_path: str = 'credentials.properties'):
|
|
943
|
+
|
|
944
|
+
config = configparser.ConfigParser(interpolation=configparser.Interpolation())
|
|
945
|
+
config.read(os.path.relpath(credentials_path), encoding='utf-8')
|
|
946
|
+
self.session: Session = requests.Session()
|
|
947
|
+
|
|
948
|
+
if request_hook is not None:
|
|
949
|
+
self.session.hooks['response'].append(request_hook)
|
|
527
950
|
|
|
951
|
+
retries = Retry(
|
|
952
|
+
total=3,
|
|
953
|
+
backoff_factor=0.1,
|
|
954
|
+
status_forcelist=[502, 503, 504],
|
|
955
|
+
allowed_methods=Retry.DEFAULT_ALLOWED_METHODS
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
self.shared_secret: bool = bool(use_shared_secret)
|
|
959
|
+
self.protocol = protocol
|
|
960
|
+
self.two_fa_secret_key = two_fa_secret_key
|
|
961
|
+
|
|
962
|
+
self.session.mount(f'{self.protocol}://', HTTPAdapter(max_retries=retries))
|
|
963
|
+
|
|
964
|
+
self.session.request = functools.partial(self.session.request, timeout=TIME_OUT)
|
|
965
|
+
|
|
966
|
+
if not two_fa_secret_key:
|
|
967
|
+
two_fa_secret_key = os.environ.get('PRESERVICA_2FA_TOKEN')
|
|
968
|
+
if two_fa_secret_key is None:
|
|
969
|
+
try:
|
|
970
|
+
two_fa_secret_key = config['credentials']['twoFactorToken']
|
|
971
|
+
except KeyError:
|
|
972
|
+
pass
|
|
973
|
+
self.two_fa_secret_key = two_fa_secret_key
|
|
528
974
|
if not username:
|
|
529
975
|
username = os.environ.get('PRESERVICA_USERNAME')
|
|
530
976
|
if username is None:
|
|
@@ -562,10 +1008,8 @@ class AuthenticatedAPI:
|
|
|
562
1008
|
pass
|
|
563
1009
|
if not tenant:
|
|
564
1010
|
msg = "No valid tenant found in method arguments, environment variables or credentials.properties file"
|
|
565
|
-
logger.
|
|
566
|
-
|
|
567
|
-
else:
|
|
568
|
-
self.tenant = tenant
|
|
1011
|
+
logger.debug(msg)
|
|
1012
|
+
self.tenant = tenant
|
|
569
1013
|
|
|
570
1014
|
if not server:
|
|
571
1015
|
server = os.environ.get('PRESERVICA_SERVER')
|
|
@@ -584,7 +1028,22 @@ class AuthenticatedAPI:
|
|
|
584
1028
|
self.token = self.__token__()
|
|
585
1029
|
self.version = self.__version_number__()
|
|
586
1030
|
self.__version_namespace__()
|
|
1031
|
+
self.roles = self._find_user_roles_()
|
|
1032
|
+
|
|
1033
|
+
self.session.headers.update({'User-Agent': f'pyPreservica SDK/({pyPreservica.__version__}) '
|
|
1034
|
+
f' ({platform.platform()}/{os.name}/{sys.platform})'})
|
|
587
1035
|
|
|
588
|
-
logger.debug(str(self))
|
|
589
1036
|
logger.debug(self.xip_ns)
|
|
590
1037
|
logger.debug(self.entity_ns)
|
|
1038
|
+
|
|
1039
|
+
def parse_date_to_iso(date):
|
|
1040
|
+
try:
|
|
1041
|
+
date = datetime.datetime.fromisoformat(date.replace('Z','+0000'))
|
|
1042
|
+
if date.tzinfo is None or date.tzinfo.utcoffset(date) is None:
|
|
1043
|
+
date = date.replace(tzinfo=datetime.timezone.utc)
|
|
1044
|
+
date = date.strftime('%Y-%m-%dT%H:%M:%S.%f%z')
|
|
1045
|
+
except ValueError:
|
|
1046
|
+
date = dateutil.parser.parse(date)
|
|
1047
|
+
if date.tzinfo is None or date.tzinfo.utcoffset(date) is None:
|
|
1048
|
+
date = date.replace(tzinfo=datetime.timezone.utc)
|
|
1049
|
+
date = date.strftime('%Y-%m-%dT%H:%M:%S.%f%z')
|