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.
@@ -0,0 +1,211 @@
1
+ """
2
+ pyPreservica WebHooksAPI module definition
3
+
4
+ A client library for the Preservica Repository web services Webhook API
5
+ https://us.preservica.com/api/webhook/documentation.html
6
+
7
+ author: James Carr
8
+ licence: Apache License 2.0
9
+
10
+ """
11
+ from http.server import BaseHTTPRequestHandler
12
+ from typing import Generator
13
+ from urllib.parse import urlparse, parse_qs
14
+ import hmac
15
+ from pyPreservica.common import *
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ BASE_ENDPOINT = '/api/webhook'
20
+
21
+ class FlaskWebhookHandler:
22
+
23
+ def __init__(self, request, secret_key: str):
24
+ from flask import request
25
+ self.request = request
26
+ self.secret_key = secret_key
27
+
28
+
29
+ def response_ok(self):
30
+ return json.dumps({'success':True}), 200, {'ContentType':'application/json'}
31
+
32
+ def is_challenge(self) -> bool:
33
+ challenge_code = self.request.args.get('challengeCode')
34
+ return challenge_code is not None
35
+
36
+ def verify_challenge(self):
37
+ challenge_code = self.request.args.get('challengeCode')
38
+ if challenge_code is not None:
39
+ challenge_response: str = hmac.new(key=bytes(self.secret_key, 'latin-1'), msg=bytes(challenge_code, 'latin-1'),
40
+ digestmod=hashlib.sha256).hexdigest()
41
+ body = json.dumps({"challengeCode": f"{challenge_code}", "challengeResponse": f"{challenge_response}"})
42
+ return body, 200, {"application/json": 'text/plain; charset=utf-8'}
43
+
44
+ return json.dumps({'success': True}), 200, {'ContentType': 'application/json'}
45
+
46
+
47
+
48
+ def process_request(self) -> Generator:
49
+ preservica_signature = self.request.headers.get('Preservica-Signature')
50
+ if preservica_signature is not None:
51
+ message_body = data = self.request.data
52
+ verify_body = f"preservica-webhook-auth{message_body.decode('utf-8')}"
53
+ digest = hmac.new(key=bytes(self.secret_key, 'latin-1'), msg=bytes(verify_body, 'latin-1'),
54
+ digestmod=hashlib.sha256).hexdigest()
55
+ if preservica_signature == digest:
56
+ json_body = json.loads(message_body.decode('utf-8'))
57
+ for event in json_body['events']:
58
+ yield event
59
+
60
+
61
+ class WebHookHandler(BaseHTTPRequestHandler):
62
+ """
63
+ A sample web hook web server which provides handshake verification
64
+ The shared secret key is passed in via the HTTPServer
65
+
66
+ Extend the class and implement do_WORK() method
67
+ The JSON document is passed into do_WORK()
68
+
69
+ """
70
+
71
+ def hmac(self, key, message):
72
+ return hmac.new(key=bytes(key, 'latin-1'), msg=bytes(message, 'latin-1'), digestmod=hashlib.sha256).hexdigest()
73
+
74
+ def do_POST(self):
75
+ result = urlparse(self.path)
76
+ q = parse_qs(result.query)
77
+ if 'challengeCode' in q:
78
+ code = q['challengeCode'][0]
79
+ signature = self.hmac(self.server.secret_key, code)
80
+ response = f'{{ "challengeCode": "{code}", "challengeResponse": "{signature}" }}'
81
+ self.send_response(200)
82
+ self.send_header("Content-type", "application/json")
83
+ self.end_headers()
84
+ self.wfile.write(bytes(response.encode('utf-8')))
85
+ self.log_message(f"Handshake Completed. {response.encode('utf-8')}")
86
+ else:
87
+ verif_sig = self.headers.get("Preservica-Signature", None)
88
+ if "chunked" in self.headers.get("Transfer-Encoding", "") and (verif_sig is not None):
89
+ payload = ""
90
+ while True:
91
+ line = self.rfile.readline().strip()
92
+ chunk_length = int(line, 16)
93
+ if chunk_length != 0:
94
+ chunk = self.rfile.read(chunk_length)
95
+ payload = payload + chunk.decode("utf-8")
96
+ self.rfile.readline()
97
+ if chunk_length == 0:
98
+ verify_body = f"preservica-webhook-auth{payload}"
99
+ signature = self.hmac(self.server.secret_key, verify_body)
100
+ if signature == verif_sig:
101
+ self.log_message("Signature Verified. Doing Work...")
102
+ self.log_message(payload)
103
+ self.send_response(200)
104
+ self.end_headers()
105
+ self.do_WORK(json.loads(payload))
106
+ break
107
+
108
+
109
+ class TriggerType(Enum):
110
+ """
111
+ Enumeration of the Web hooks Trigger Types
112
+ """
113
+ MOVED = "MOVED"
114
+ INDEXED = "FULL_TEXT_INDEXED"
115
+ SECURITY_CHANGED = "CHANGED_SECURITY_DESCRIPTOR"
116
+ INGEST_FAILED = "INGEST_FAILED"
117
+ CHANGE_ASSET_VISIBILITY = "CHANGE_ASSET_VISIBILITY"
118
+
119
+
120
+ class WebHooksAPI(AuthenticatedAPI):
121
+ """
122
+ Class to register new webhook endpoints
123
+
124
+ """
125
+
126
+ def subscriptions(self):
127
+ """
128
+ Return all the current active web hook subscriptions as a json document
129
+
130
+ :return: list of web hooks
131
+ """
132
+ self._check_if_user_has_manager_role()
133
+ headers = {HEADER_TOKEN: self.token}
134
+ response = self.session.get(f'{self.protocol}://{self.server}{BASE_ENDPOINT}/subscriptions', headers=headers)
135
+ if response.status_code == requests.codes.unauthorized:
136
+ self.token = self.__token__()
137
+ return self.subscriptions()
138
+ if response.status_code == requests.codes.ok:
139
+ json_response = str(response.content.decode('utf-8'))
140
+ doc = json.loads(json_response)
141
+ return doc
142
+ else:
143
+ exception = HTTPException("", response.status_code, response.url, "subscriptions",
144
+ response.content.decode('utf-8'))
145
+ logger.error(exception)
146
+ raise exception
147
+
148
+ def unsubscribe_all(self):
149
+ """
150
+ Unsubscribe from all webhooks.
151
+ :return:
152
+ """
153
+ self._check_if_user_has_manager_role()
154
+ subscriptions = self.subscriptions()
155
+ for sub in subscriptions:
156
+ self.unsubscribe(sub['id'])
157
+
158
+ def unsubscribe(self, subscription_id: str):
159
+ """
160
+ Unsubscribe from the provided webhook.
161
+
162
+ :param subscription_id:
163
+ :return:
164
+ """
165
+ self._check_if_user_has_manager_role()
166
+ headers = {HEADER_TOKEN: self.token}
167
+ response = self.session.delete(
168
+ f'{self.protocol}://{self.server}{BASE_ENDPOINT}/subscriptions/{subscription_id}',
169
+ headers=headers)
170
+ if response.status_code == requests.codes.unauthorized:
171
+ self.token = self.__token__()
172
+ return self.unsubscribe(subscription_id)
173
+ if response.status_code == requests.codes.no_content:
174
+ json_response = str(response.content.decode('utf-8'))
175
+ logger.debug(json_response)
176
+ return json_response
177
+ else:
178
+ exception = HTTPException(str(subscription_id), response.status_code, response.url, "unsubscribe",
179
+ response.content.decode('utf-8'))
180
+ logger.error(exception)
181
+ raise exception
182
+
183
+ def subscribe(self, url: str, triggerType: TriggerType, secret: str):
184
+ """
185
+ Subscribe to a new web hook
186
+
187
+ :param url:
188
+ :param triggerType:
189
+ :param secret:
190
+ :return: json_response
191
+ """
192
+ self._check_if_user_has_manager_role()
193
+ headers = {HEADER_TOKEN: self.token, 'Accept': 'application/json', 'Content-Type': 'application/json'}
194
+
195
+ json_payload = f'{{"url": "{url}", "triggerType": "{triggerType.value}", "secret": "{secret}", ' \
196
+ f'"includeIdentifiers": "true"}}'
197
+
198
+ response = self.session.post(f'{self.protocol}://{self.server}{BASE_ENDPOINT}/subscriptions', headers=headers,
199
+ data=json.dumps(json.loads(json_payload)))
200
+ if response.status_code == requests.codes.unauthorized:
201
+ self.token = self.__token__()
202
+ return self.subscribe(url, triggerType, secret)
203
+ if response.status_code == requests.codes.ok:
204
+ json_response = str(response.content.decode('utf-8'))
205
+ logger.debug(json_response)
206
+ return json_response
207
+ else:
208
+ exception = HTTPException(str(url), response.status_code, response.url, "subscribe",
209
+ response.content.decode('utf-8'))
210
+ logger.error(response.content.decode('utf-8'))
211
+ raise exception
@@ -1,6 +1,17 @@
1
+ """
2
+ pyPreservica WorkflowAPI module definition
3
+
4
+ A client library for the Preservica Repository web services Workflow API
5
+ https://us.preservica.com/sdb/rest/workflow/documentation.html
6
+
7
+ author: James Carr
8
+ licence: Apache License 2.0
9
+
10
+ """
11
+
1
12
  import uuid
2
13
  import datetime
3
- from xml.dom import minidom
14
+ from typing import Callable
4
15
  from xml.etree import ElementTree
5
16
 
6
17
  from pyPreservica.common import *
@@ -8,15 +19,12 @@ from pyPreservica.common import *
8
19
  logger = logging.getLogger(__name__)
9
20
 
10
21
 
11
- def prettify(elem):
12
- """Return a pretty-printed XML string for the Element.
22
+ class WorkflowInstance:
23
+ """
24
+ Defines a workflow Instance.
25
+ The workflow Instance is a context which has been executed
13
26
  """
14
- rough_string = ElementTree.tostring(elem, 'utf-8')
15
- re_parsed = minidom.parseString(rough_string)
16
- return re_parsed.toprettyxml(indent=" ")
17
-
18
27
 
19
- class WorkflowInstance:
20
28
  def __init__(self, instance_id: int):
21
29
  self.instance_id = instance_id
22
30
  self.started = None
@@ -28,6 +36,7 @@ class WorkflowInstance:
28
36
  self.workflow_context_id = None
29
37
  self.workflow_context_name = None
30
38
  self.workflow_definition_id = None
39
+ self.xml_response = None
31
40
 
32
41
  def __str__(self):
33
42
  return f"Workflow Instance ID: {self.instance_id}"
@@ -37,13 +46,20 @@ class WorkflowInstance:
37
46
 
38
47
 
39
48
  class WorkflowContext:
40
- def __init__(self, workflow_id, workflow_name):
49
+ """
50
+ Defines a workflow context.
51
+ The workflow context is the pre-defined workflow which is ready to run
52
+ """
53
+
54
+ def __init__(self, workflow_id, workflow_name: str):
41
55
  self.workflow_id = workflow_id
42
56
  self.workflow_name = workflow_name
43
57
 
44
58
  def __str__(self):
45
- return f"Workflow ID:\t\t\t{self.workflow_id}\n" \
46
- f"Workflow Name:\t\t\t{self.workflow_name}\n"
59
+ return f"""
60
+ Workflow ID: {self.workflow_id}
61
+ Workflow Name: {self.workflow_name}
62
+ """
47
63
 
48
64
  def __repr__(self):
49
65
  return self.__str__()
@@ -51,8 +67,11 @@ class WorkflowContext:
51
67
 
52
68
  class WorkflowAPI(AuthenticatedAPI):
53
69
  """
54
- A client library for the Preservica Workflow API
55
- https://preview.preservica.com/sdb/rest/workflow/documentation.html
70
+ A class for calling the Preservica Workflow API
71
+
72
+ This API can be used to programmatically manage the Preservica Workflows.
73
+
74
+ https://preview.preservica.com/sdb/rest/workflow/documentation.html
56
75
 
57
76
  """
58
77
 
@@ -60,22 +79,30 @@ class WorkflowAPI(AuthenticatedAPI):
60
79
  'Failed']
61
80
  workflow_types = ['Ingest', 'Access', 'Transformation', 'DataManagement']
62
81
 
63
- def __init__(self, username=None, password=None, tenant=None, server=None, use_shared_secret=False):
64
- super().__init__(username, password, tenant, server, use_shared_secret)
82
+ def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
83
+ use_shared_secret: bool = False, two_fa_secret_key: str = None,
84
+ protocol: str = "https", request_hook: Callable = None, credentials_path: str = 'credentials.properties'):
85
+
86
+ super().__init__(username, password, tenant, server, use_shared_secret, two_fa_secret_key,
87
+ protocol, request_hook, credentials_path)
65
88
  self.base_url = "sdb/rest/workflow"
66
89
 
67
90
  def get_workflow_contexts_by_type(self, workflow_type: str):
68
91
  """
69
92
  Return a list of Workflow Contexts which have the same Workflow type
70
93
 
71
- :param workflow_type: The Workflow type:
72
- Ingest, Access, Transformation or DataManagement
94
+ :param workflow_type: The Workflow type Ingest, Access, Transformation or DataManagement
95
+ :type workflow_type: str
96
+
97
+ :return: List of Workflow Contexts
98
+ :rtype: list
73
99
 
74
100
  """
101
+
75
102
  headers = {HEADER_TOKEN: self.token}
76
103
  params = {"type": workflow_type}
77
- workflow_contexts = list()
78
- request = requests.get(f'https://{self.server}/{self.base_url}/contexts', headers=headers, params=params)
104
+ workflow_contexts = []
105
+ request = self.session.get(f'{self.protocol}://{self.server}/{self.base_url}/contexts', headers=headers, params=params)
79
106
  if request.status_code == requests.codes.ok:
80
107
  xml_response = str(request.content.decode('utf-8'))
81
108
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -95,15 +122,20 @@ class WorkflowAPI(AuthenticatedAPI):
95
122
 
96
123
  def get_workflow_contexts(self, definition: str):
97
124
  """
98
- Return a list of Workflow Contexts which have the same Workflow Definition ID
125
+ Return a list of Workflow Contexts which have the same Workflow Definition
99
126
 
100
127
  :param definition: The Workflow Definition ID
128
+ :type definition: str
129
+
130
+ :return: List of Workflow Contexts
131
+ :rtype: list
101
132
 
102
133
  """
134
+
103
135
  headers = {HEADER_TOKEN: self.token}
104
136
  params = {"workflowDefinitionId": definition}
105
- workflow_contexts = list()
106
- request = requests.get(f'https://{self.server}/{self.base_url}/contexts', headers=headers, params=params)
137
+ workflow_contexts = []
138
+ request = self.session.get(f'{self.protocol}://{self.server}/{self.base_url}/contexts', headers=headers, params=params)
107
139
  if request.status_code == requests.codes.ok:
108
140
  xml_response = str(request.content.decode('utf-8'))
109
141
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -121,17 +153,22 @@ class WorkflowAPI(AuthenticatedAPI):
121
153
  logger.error(request.content)
122
154
  raise RuntimeError(request.status_code, "get_workflow_contexts")
123
155
 
124
- def start_workflow_instance(self, workflow_context, **kwargs):
156
+ def start_workflow_instance(self, workflow_context: WorkflowContext, **kwargs):
125
157
  """
126
-
127
158
  Start a workflow context
128
159
 
129
160
  Returns a Correlation Id which is used to monitor the workflow progress
130
161
 
131
162
  :param workflow_context: The workflow context to start
132
- :param kwargs: Key/Values to pass to the workflow instance
163
+ :type workflow_context: WorkflowContext
164
+
165
+ :param kwargs: Key/Values to pass to the workflow instance
166
+
167
+ :return: correlation_id
168
+ :rtype: str
133
169
 
134
170
  """
171
+
135
172
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
136
173
 
137
174
  correlation_id = str(uuid.uuid4())
@@ -149,7 +186,8 @@ class WorkflowAPI(AuthenticatedAPI):
149
186
  xml.etree.ElementTree.SubElement(request_payload, "CorrelationId").text = correlation_id
150
187
 
151
188
  xml_request = xml.etree.ElementTree.tostring(request_payload, encoding='utf-8')
152
- request = requests.post(f'https://{self.server}/{self.base_url}/instances', headers=headers, data=xml_request)
189
+ request = self.session.post(f'{self.protocol}://{self.server}/{self.base_url}/instances', headers=headers,
190
+ data=xml_request)
153
191
  if request.status_code == requests.codes.created:
154
192
  return correlation_id
155
193
  if request.status_code == requests.codes.unauthorized:
@@ -163,9 +201,11 @@ class WorkflowAPI(AuthenticatedAPI):
163
201
  """
164
202
  Terminate a workflow by its instance id
165
203
 
166
- :param instance_ids: The Workflow instance:
204
+ :param instance_ids: The Workflow instance
205
+ :type instance_ids: int or a list of int
167
206
 
168
207
  """
208
+
169
209
  if isinstance(instance_ids, list):
170
210
  converted_list = [str(int(e)) for e in instance_ids]
171
211
  param_string = ",".join(converted_list)
@@ -174,8 +214,8 @@ class WorkflowAPI(AuthenticatedAPI):
174
214
 
175
215
  headers = {HEADER_TOKEN: self.token}
176
216
  params = {"workflowInstanceIds": param_string}
177
- request = requests.post(f'https://{self.server}/{self.base_url}/instances/terminate',
178
- headers=headers, params=params)
217
+ request = self.session.post(f'{self.protocol}://{self.server}/{self.base_url}/instances/terminate',
218
+ headers=headers, params=params)
179
219
  if request.status_code == requests.codes.accepted:
180
220
  return
181
221
  elif request.status_code == requests.codes.unauthorized:
@@ -185,15 +225,22 @@ class WorkflowAPI(AuthenticatedAPI):
185
225
  logger.error(request.content)
186
226
  raise RuntimeError(request.status_code, "terminate_workflow_instance")
187
227
 
188
- def workflow_instance(self, instance_id: int):
228
+ def workflow_instance(self, instance_id: int) -> WorkflowInstance:
189
229
  """
190
230
  Return a workflow instance by its Id
191
231
 
192
- :param instance_id: The Workflow instance:
232
+ :param instance_id: The Workflow instance
233
+ :type instance_id: int
234
+
235
+ :return: workflow_instance
236
+ :rtype: WorkflowInstance
193
237
 
194
238
  """
239
+
195
240
  headers = {HEADER_TOKEN: self.token}
196
- request = requests.get(f'https://{self.server}/{self.base_url}/instances/{str(instance_id)}', headers=headers)
241
+ params = {"includeErrors": "true"}
242
+ request = self.session.get(f'{self.protocol}://{self.server}/{self.base_url}/instances/{str(instance_id)}',
243
+ headers=headers, params=params)
197
244
  if request.status_code == requests.codes.ok:
198
245
  xml_response = str(request.content.decode('utf-8'))
199
246
  logger.debug(xml_response)
@@ -202,13 +249,13 @@ class WorkflowAPI(AuthenticatedAPI):
202
249
  assert instance_id == w_id
203
250
  workflow_instance = WorkflowInstance(int(instance_id))
204
251
  started_element = entity_response.find(f".//{{{NS_WORKFLOW}}}Started")
205
- if started_element:
252
+ if started_element is not None:
206
253
  if hasattr(started_element, "text"):
207
254
  workflow_instance.started = datetime.datetime.strptime(started_element.text,
208
255
  '%Y-%m-%dT%H:%M:%S.%fZ')
209
256
 
210
257
  finished_element = entity_response.find(f".//{{{NS_WORKFLOW}}}Finished")
211
- if finished_element:
258
+ if finished_element is not None:
212
259
  if hasattr(finished_element, "text"):
213
260
  workflow_instance.finished = datetime.datetime.strptime(finished_element.text,
214
261
  '%Y-%m-%dT%H:%M:%S.%fZ')
@@ -223,6 +270,8 @@ class WorkflowAPI(AuthenticatedAPI):
223
270
  workflow_instance.workflow_definition_id = entity_response.find(
224
271
  f".//{{{NS_WORKFLOW}}}WorkflowDefinitionTextId").text
225
272
 
273
+ workflow_instance.xml_response = xml_response
274
+
226
275
  return workflow_instance
227
276
  elif request.status_code == requests.codes.unauthorized:
228
277
  self.token = self.__token__()
@@ -235,10 +284,11 @@ class WorkflowAPI(AuthenticatedAPI):
235
284
  """
236
285
  Return a list of Workflow instances
237
286
 
238
- :param workflow_state: The Workflow state: Aborted, Active, Completed, Finished_Mixed_Outcome, Pending, Suspended, Unknown, or Failed
239
- :param workflow_type: The Workflow type: Ingest, Access, Transformation or DataManagement
287
+ :param workflow_state: The Workflow state Aborted, Active, Completed, Finished_Mixed_Outcome, Pending, Suspended, Unknown, or Failed
288
+ :param workflow_type: The Workflow type Ingest, Access, Transformation or DataManagement
240
289
 
241
290
  """
291
+
242
292
  start_value = int(0)
243
293
  maximum = int(25)
244
294
  total_count = maximum
@@ -256,10 +306,12 @@ class WorkflowAPI(AuthenticatedAPI):
256
306
  """
257
307
  Return a list of Workflow instances
258
308
 
259
- :param workflow_state: The Workflow state: Aborted, Active, Completed, Finished_Mixed_Outcome, Pending, Suspended, Unknown, or Failed
309
+ :param workflow_state: The Workflow state: Aborted, Active, Completed, Finished_Mixed_Outcome, Pending,
310
+ Suspended, Unknown, or Failed
260
311
  :param workflow_type: The Workflow type: Ingest, Access, Transformation or DataManagement
261
312
 
262
313
  """
314
+
263
315
  headers = {HEADER_TOKEN: self.token}
264
316
 
265
317
  if workflow_state not in self.workflow_states:
@@ -280,18 +332,18 @@ class WorkflowAPI(AuthenticatedAPI):
280
332
  creator = kwargs.get("creator")
281
333
  params["creator"] = creator
282
334
 
283
- if "from" in kwargs:
284
- from_date = kwargs.get("from")
285
- params["from"] = from_date
335
+ if "from_date" in kwargs:
336
+ from_date = kwargs.get("from_date")
337
+ params["from"] = parse_date_to_iso(from_date)
286
338
 
287
- if "to" in kwargs:
288
- to_date = kwargs.get("to")
289
- params["to"] = to_date
339
+ if "to_date" in kwargs:
340
+ to_date = kwargs.get("to_date")
341
+ params["to"] = parse_date_to_iso(to_date)
290
342
 
291
343
  params["start"] = int(start_value)
292
344
  params["max"] = int(maximum)
293
345
 
294
- request = requests.get(f'https://{self.server}/{self.base_url}/instances', headers=headers, params=params)
346
+ request = self.session.get(f'{self.protocol}://{self.server}/{self.base_url}/instances', headers=headers, params=params)
295
347
  if request.status_code == requests.codes.ok:
296
348
  xml_response = str(request.content.decode('utf-8'))
297
349
  logger.debug(xml_response)
@@ -299,19 +351,19 @@ class WorkflowAPI(AuthenticatedAPI):
299
351
  total_count = int(entity_response.find(f".//{{{NS_WORKFLOW}}}TotalCount").text)
300
352
  count = int(entity_response.find(f".//{{{NS_WORKFLOW}}}Count").text)
301
353
  workflow_instance = entity_response.findall(f".//{{{NS_WORKFLOW}}}WorkflowInstance")
302
- workflow_instances = list()
354
+ workflow_instances = []
303
355
  for instance in workflow_instance:
304
356
  instance_id = instance.find(f".//{{{NS_WORKFLOW}}}Id").text
305
357
  workflow_instance = WorkflowInstance(int(instance_id))
306
358
 
307
359
  started_element = instance.find(f".//{{{NS_WORKFLOW}}}Started")
308
- if started_element:
360
+ if started_element is not None:
309
361
  if hasattr(started_element, "text"):
310
362
  workflow_instance.started = datetime.datetime.strptime(started_element.text,
311
363
  '%Y-%m-%dT%H:%M:%S.%fZ')
312
364
 
313
365
  finished_element = instance.find(f".//{{{NS_WORKFLOW}}}Finished")
314
- if finished_element:
366
+ if finished_element is not None:
315
367
  if hasattr(finished_element, "text"):
316
368
  workflow_instance.finished = datetime.datetime.strptime(finished_element.text,
317
369
  '%Y-%m-%dT%H:%M:%S.%fZ')