sapiopycommons 2025.4.9a150__py3-none-any.whl → 2025.4.9a476__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 sapiopycommons might be problematic. Click here for more details.

Files changed (42) hide show
  1. sapiopycommons/callbacks/callback_util.py +1262 -392
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/Molecules.py +0 -2
  4. sapiopycommons/customreport/auto_pagers.py +281 -0
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/attachment_util.py +4 -2
  7. sapiopycommons/datatype/data_fields.py +23 -1
  8. sapiopycommons/eln/experiment_cache.py +173 -0
  9. sapiopycommons/eln/experiment_handler.py +933 -279
  10. sapiopycommons/eln/experiment_report_util.py +15 -10
  11. sapiopycommons/eln/experiment_step_factory.py +474 -0
  12. sapiopycommons/eln/experiment_tags.py +7 -0
  13. sapiopycommons/eln/plate_designer.py +159 -59
  14. sapiopycommons/eln/step_creation.py +235 -0
  15. sapiopycommons/files/file_bridge.py +76 -0
  16. sapiopycommons/files/file_bridge_handler.py +325 -110
  17. sapiopycommons/files/file_data_handler.py +2 -2
  18. sapiopycommons/files/file_util.py +40 -15
  19. sapiopycommons/files/file_validator.py +6 -5
  20. sapiopycommons/files/file_writer.py +1 -1
  21. sapiopycommons/flowcyto/flow_cyto.py +1 -1
  22. sapiopycommons/general/accession_service.py +3 -3
  23. sapiopycommons/general/aliases.py +51 -28
  24. sapiopycommons/general/audit_log.py +2 -2
  25. sapiopycommons/general/custom_report_util.py +24 -1
  26. sapiopycommons/general/data_structure_util.py +115 -0
  27. sapiopycommons/general/directive_util.py +86 -0
  28. sapiopycommons/general/exceptions.py +41 -2
  29. sapiopycommons/general/popup_util.py +2 -2
  30. sapiopycommons/multimodal/multimodal.py +1 -0
  31. sapiopycommons/processtracking/custom_workflow_handler.py +46 -30
  32. sapiopycommons/recordmodel/record_handler.py +547 -159
  33. sapiopycommons/rules/eln_rule_handler.py +41 -30
  34. sapiopycommons/rules/on_save_rule_handler.py +41 -30
  35. sapiopycommons/samples/aliquot.py +48 -0
  36. sapiopycommons/webhook/webhook_handlers.py +448 -55
  37. sapiopycommons/webhook/webservice_handlers.py +2 -2
  38. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/METADATA +1 -1
  39. sapiopycommons-2025.4.9a476.dist-info/RECORD +67 -0
  40. sapiopycommons-2025.4.9a150.dist-info/RECORD +0 -59
  41. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/WHEEL +0 -0
  42. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/licenses/LICENSE +0 -0
@@ -1,28 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
1
6
  import traceback
2
7
  from abc import abstractmethod
3
8
  from logging import Logger
4
9
 
10
+ from sapiopylib.rest import UserManagerService, GroupManagerService, MessengerService
11
+ from sapiopylib.rest.AccessionService import AccessionManager
12
+ from sapiopylib.rest.CustomReportService import CustomReportManager
13
+ from sapiopylib.rest.DashboardManager import DashboardManager
5
14
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
6
15
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
16
+ from sapiopylib.rest.DataService import DataManager
17
+ from sapiopylib.rest.DataTypeService import DataTypeManager
18
+ from sapiopylib.rest.ELNService import ElnManager
19
+ from sapiopylib.rest.PicklistService import PickListManager
20
+ from sapiopylib.rest.ReportManager import ReportManager
21
+ from sapiopylib.rest.SesssionManagerService import SessionManager
7
22
  from sapiopylib.rest.User import SapioUser
8
23
  from sapiopylib.rest.WebhookService import AbstractWebhookHandler
9
24
  from sapiopylib.rest.pojo.Message import VeloxLogMessage, VeloxLogLevel
25
+ from sapiopylib.rest.pojo.webhook.ClientCallbackRequest import PopupType
10
26
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
27
  from sapiopylib.rest.pojo.webhook.WebhookEnums import WebhookEndpointType
12
28
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
13
29
  from sapiopylib.rest.utils.DataTypeCacheManager import DataTypeCacheManager
30
+ from sapiopylib.rest.utils.FoundationAccessioning import FoundationAccessionManager
14
31
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
15
32
  RecordModelRelationshipManager
16
33
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
34
+ from sapiopylib.rest.utils.recordmodel.last_saved import LastSavedValueManager
17
35
 
18
36
  from sapiopycommons.callbacks.callback_util import CallbackUtil
19
37
  from sapiopycommons.eln.experiment_handler import ExperimentHandler
38
+ from sapiopycommons.general.directive_util import DirectiveUtil
20
39
  from sapiopycommons.general.exceptions import SapioUserErrorException, SapioCriticalErrorException, \
21
- SapioUserCancelledException, SapioException, SapioDialogTimeoutException
40
+ SapioUserCancelledException, SapioException, SapioDialogTimeoutException, MessageDisplayType
22
41
  from sapiopycommons.general.sapio_links import SapioNavigationLinker
42
+ from sapiopycommons.general.time_util import TimeUtil
23
43
  from sapiopycommons.recordmodel.record_handler import RecordHandler
24
44
  from sapiopycommons.rules.eln_rule_handler import ElnRuleHandler
25
45
  from sapiopycommons.rules.on_save_rule_handler import OnSaveRuleHandler
46
+ from sapiopycommons.webhook.webhook_context import ProcessQueueContext
26
47
 
27
48
 
28
49
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
@@ -34,37 +55,156 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
34
55
  """
35
56
  logger: Logger
36
57
 
58
+ _start_time: float
59
+ _start_time_epoch: int
60
+
37
61
  user: SapioUser
62
+ """The user who invoked this webhook. Used for authenticating requests back to the Sapio server."""
63
+ group_name: str
64
+ """The name of the group that the user invoked this webhook with."""
65
+ user_utc_offset_seconds: int
66
+ """The number of seconds that the user is offset from the UTC timezone. Able to be used with TimeUtil to display
67
+ timestamps in the user's timezone."""
38
68
  context: SapioWebhookContext
39
-
69
+ """The context from the server of this webhook's invocation."""
70
+
71
+ # CR-47383: Include every manager from DataMgmtServer for easier access.
72
+ acc_man: AccessionManager
73
+ """A class for making requests to the accession webservice endpoints."""
74
+ fnd_acc_man: FoundationAccessionManager
75
+ """A class for making requests to the Foundations accession webservice endpoints."""
76
+ report_man: CustomReportManager
77
+ """A class for making requests to the custom report webservice endpoints."""
78
+ dash_man: DashboardManager
79
+ """A class for making requests to the dashboard management webservice endpoints."""
80
+ xml_data_man: DataManager
81
+ """A class for making requests to the data record import/export via XML webservice endpoints."""
40
82
  dr_man: DataRecordManager
83
+ """A class for making requests to the data record webservice endpoints."""
84
+ dt_man: DataTypeManager
85
+ """A class for making requests to the data type webservice endpoints."""
86
+ eln_man: ElnManager
87
+ """A class for making requests to the ELN management webservice endpoints."""
88
+ group_man: GroupManagerService
89
+ """A class for making requests to the group management webservice endpoints."""
90
+ messenger: MessengerService
91
+ """A class for making requests to the message webservice endpoints."""
92
+ list_man: PickListManager
93
+ """A class for making requests to the pick list webservice endpoints."""
94
+ pdf_report_man: ReportManager
95
+ """A class for making requests to the report webservice endpoints."""
96
+ session_man: SessionManager
97
+ """A class for making requests to the session management webservice endpoints."""
98
+ user_man: UserManagerService
99
+ """A class for making requests to the user management webservice endpoints."""
100
+
41
101
  rec_man: RecordModelManager
102
+ """The record model manager. Used for committing record model changes to the system."""
42
103
  inst_man: RecordModelInstanceManager
104
+ """The record model instance manager. Used for adding record models to the cache."""
43
105
  rel_man: RecordModelRelationshipManager
106
+ """The record model relationship manager. Used for loading parent/child and side-link relationships between record
107
+ models."""
44
108
  # FR-46329: Add the ancestor manager to CommonsWebhookHandler.
45
109
  an_man: RecordModelAncestorManager
110
+ """The record model ancestor manager. Used for loading ancestor relationships between record models."""
111
+ saved_vals_man: LastSavedValueManager
112
+ """The last saved values manager. Used for determining what the record values were prior to this commit."""
46
113
 
47
114
  dt_cache: DataTypeCacheManager
115
+ """A class that calls the same endpoints as the DataTypeManager (self.dt_man), except the results are cached so that
116
+ repeated calls to the same function don't result in duplicate webservice calls. """
48
117
  rec_handler: RecordHandler
118
+ """A class that behaves like a combination between the DataRecordManager and RecordModelInstanceManager, allowing
119
+ you to query and wrap record as record models in a single function call, among other functions useful for dealing
120
+ with record models."""
49
121
  callback: CallbackUtil
122
+ """A class for making requests to the client callback webservice endpoints."""
123
+ directive: DirectiveUtil
124
+ """A class for making directives that redirect the user to a new webpage after this webhook returns a result."""
50
125
  exp_handler: ExperimentHandler | None
126
+ """If this webhook was invoked from within an ELN experiment, this variable will be populated with an
127
+ ExperimentHandler initialized from the context. """
51
128
  rule_handler: OnSaveRuleHandler | ElnRuleHandler | None
129
+ """If this is an ELN or on save rule endpoint, this variable will be populated with an ElnRuleHandler or
130
+ OnSaveRuleHandler, depending on the endpoint type."""
131
+ custom_context: ProcessQueueContext | None
132
+ """If this is a custom endpoint, this variable will be populated with an object that parses the custom context
133
+ data."""
134
+
135
+ # FR-47390: Allow for classes that extend CommonsWebhookHandler to change how exception messages are displayed
136
+ # to the user be changing these variables instead of needing to override the exception handling functions.
137
+ default_user_error_display_type: MessageDisplayType
138
+ """The default message display type for user error exceptions. If a user error exception is thrown and doesn't
139
+ specify a display type, this type will be used."""
140
+ default_critical_error_display_type: MessageDisplayType
141
+ """The default message display type for critical error exceptions. If a critical error exception is thrown and
142
+ doesn't specify a display type, this type will be used."""
143
+ default_dialog_timeout_display_type: MessageDisplayType
144
+ """The default message display type for dialog timeout exceptions."""
145
+ default_unexpected_error_display_type: MessageDisplayType
146
+ """The default message display type for unexpected exceptions."""
147
+
148
+ default_user_error_title: str
149
+ """The default title to display to the user when a user error occurs. If a user error exception is thrown and
150
+ doesn't specify a title, this title will be used."""
151
+ default_critical_error_title: str
152
+ """The default title to display to the user when a critical error occurs. If a critical error exception is thrown
153
+ and doesn't specify a title, this title will be used."""
154
+ default_dialog_timeout_title: str
155
+ """The default title to display to the user when a dialog times out."""
156
+ default_unexpected_error_title: str
157
+ """The default title to display to the user when an unexpected exception occurs."""
158
+
159
+ default_dialog_timeout_message: str
160
+ """The default message to display to the user when a dialog times out."""
161
+ default_unexpected_error_message: str
162
+ """The default message to display to the user when an unexpected exception occurs."""
52
163
 
53
164
  def run(self, context: SapioWebhookContext) -> SapioWebhookResult:
54
- self.user = context.user
165
+ # Timestamps used for measuring performance.
166
+ self._start_time = time.perf_counter()
167
+ self._start_time_epoch = TimeUtil.now_in_millis()
168
+
169
+ # Save the webhook context so that any function of this class can access it.
55
170
  self.context = context
56
171
 
172
+ # Save the user and commonly sought after user information.
173
+ self.user = context.user
174
+ self.group_name = self.user.session_additional_data.current_group_name
175
+ self.user_utc_offset_seconds = self.user.session_additional_data.utc_offset_seconds
176
+
177
+ # Get the logger from the user.
57
178
  self.logger = self.user.logger
58
179
 
180
+ # Initialize basic manager classes from sapiopylib.
181
+ self.acc_man = DataMgmtServer.get_accession_manager(self.user)
182
+ self.report_man = DataMgmtServer.get_custom_report_manager(self.user)
183
+ self.dash_man = DataMgmtServer.get_dashboard_manager(self.user)
184
+ self.xml_data_man = DataMgmtServer.get_data_manager(self.user)
59
185
  self.dr_man = context.data_record_manager
186
+ self.dt_man = DataMgmtServer.get_data_type_manager(self.user)
187
+ self.eln_man = context.eln_manager
188
+ self.group_man = DataMgmtServer.get_group_manager(self.user)
189
+ self.messenger = DataMgmtServer.get_messenger(self.user)
190
+ self.list_man = DataMgmtServer.get_picklist_manager(self.user)
191
+ self.pdf_report_man = DataMgmtServer.get_report_manager(self.user)
192
+ self.session_man = DataMgmtServer.get_session_manager(self.user)
193
+ self.user_man = DataMgmtServer.get_user_manager(self.user)
194
+
195
+ # Initialize record model managers.
60
196
  self.rec_man = RecordModelManager(self.user)
61
197
  self.inst_man = self.rec_man.instance_manager
62
198
  self.rel_man = self.rec_man.relationship_manager
63
- self.an_man = RecordModelAncestorManager(self.rec_man)
199
+ self.an_man = self.rec_man.ancestor_manager
200
+ self.saved_vals_man = self.rec_man.last_saved_manager
64
201
 
202
+ # Initialize more complex classes from sapiopylib and sapiopycommons.
203
+ self.fnd_acc_man = FoundationAccessionManager(self.user)
65
204
  self.dt_cache = DataTypeCacheManager(self.user)
66
205
  self.rec_handler = RecordHandler(context)
67
206
  self.callback = CallbackUtil(context)
207
+ self.directive = DirectiveUtil(context)
68
208
  if context.eln_experiment is not None:
69
209
  self.exp_handler = ExperimentHandler(context)
70
210
  else:
@@ -75,6 +215,29 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
75
215
  self.rule_handler = ElnRuleHandler(context)
76
216
  else:
77
217
  self.rule_handler = None
218
+ if self.is_custom():
219
+ self.custom_context = ProcessQueueContext(context)
220
+ else:
221
+ self.custom_context = None
222
+
223
+ # CR-47526: Set the dialog timeout to 1 hour by default. This can be overridden by the webhook.
224
+ self.callback.set_dialog_timeout(3600)
225
+
226
+ # Set the default display types, titles, and messages for each type of exception that can display a message.
227
+ self.default_user_error_display_type = MessageDisplayType.TOASTER_WARNING
228
+ self.default_critical_error_display_type = MessageDisplayType.DISPLAY_ERROR
229
+ self.default_dialog_timeout_display_type = MessageDisplayType.OK_DIALOG
230
+ self.default_unexpected_error_display_type = MessageDisplayType.TOASTER_WARNING
231
+
232
+ self.default_user_error_title = ""
233
+ self.default_critical_error_title = ""
234
+ self.default_dialog_timeout_title = "Dialog Timeout"
235
+ self.default_unexpected_error_title = ""
236
+
237
+ self.default_dialog_timeout_message = ("You have remained idle for too long and this dialog has timed out. "
238
+ "Close and re-initiate it to continue.")
239
+ self.default_unexpected_error_message = ("Unexpected error occurred during webhook execution. Please contact "
240
+ "Sapio support.")
78
241
 
79
242
  # Wrap the execution of each webhook in a try/catch. If an exception occurs, handle any special sapiopycommons
80
243
  # exceptions. Otherwise, return a generic message stating that an error occurred.
@@ -124,7 +287,18 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
124
287
  if result is not None:
125
288
  return result
126
289
  self.log_error(traceback.format_exc())
127
- return SapioWebhookResult(False, display_text=e.args[0])
290
+ self.handle_user_error_exception_extra(e)
291
+
292
+ display_type: MessageDisplayType = e.display_type if e.display_type else self.default_user_error_display_type
293
+ title: str = e.title if e.title is not None else self.default_user_error_title
294
+ return self._display_exception(e.msg, display_type, title)
295
+
296
+ def handle_user_error_exception_extra(self, e: SapioUserErrorException) -> None:
297
+ """
298
+ An additional function that can be overridden to provide extra behavior when a SapioUserErrorException is thrown.
299
+ Default behavior does nothing.
300
+ """
301
+ pass
128
302
 
129
303
  def handle_critical_error_exception(self, e: SapioCriticalErrorException) -> SapioWebhookResult:
130
304
  """
@@ -139,13 +313,18 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
139
313
  if result is not None:
140
314
  return result
141
315
  self.log_error(traceback.format_exc())
142
- # This error can be thrown by endpoints that can't send client callbacks. If that happens, fall back onto
143
- # sending display text instead.
144
- if self.can_send_client_callback():
145
- self.callback.display_error(e.args[0])
146
- else:
147
- return SapioWebhookResult(False, e.args[0])
148
- return SapioWebhookResult(False)
316
+ self.handle_critical_error_exception_extra(e)
317
+
318
+ display_type: MessageDisplayType = e.display_type if e.display_type else self.default_critical_error_display_type
319
+ title: str = e.title if e.title is not None else self.default_critical_error_title
320
+ return self._display_exception(e.msg, display_type, title)
321
+
322
+ def handle_critical_error_exception_extra(self, e: SapioCriticalErrorException) -> None:
323
+ """
324
+ An additional function that can be overridden to provide extra behavior when a SapioCriticalErrorException is
325
+ thrown. Default behavior does nothing.
326
+ """
327
+ pass
149
328
 
150
329
  def handle_user_cancelled_exception(self, e: SapioUserCancelledException) -> SapioWebhookResult:
151
330
  """
@@ -160,7 +339,17 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
160
339
  result: SapioWebhookResult | None = self.handle_any_exception(e)
161
340
  if result is not None:
162
341
  return result
163
- return SapioWebhookResult(True)
342
+ self.handle_user_cancelled_exception_extra(e)
343
+ # FR-47390: Return a False result for user cancelled exceptions so that transactional webhooks cancel the
344
+ # commit.
345
+ return SapioWebhookResult(False)
346
+
347
+ def handle_user_cancelled_exception_extra(self, e: SapioUserCancelledException) -> None:
348
+ """
349
+ An additional function that can be overridden to provide extra behavior when a SapioUserCancelledException is
350
+ thrown. Default behavior does nothing.
351
+ """
352
+ pass
164
353
 
165
354
  def handle_dialog_timeout_exception(self, e: SapioDialogTimeoutException) -> SapioWebhookResult:
166
355
  """
@@ -175,15 +364,17 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
175
364
  result: SapioWebhookResult | None = self.handle_any_exception(e)
176
365
  if result is not None:
177
366
  return result
178
- # This dialog could time out too! Ignore it if it does.
179
- # No need to check can_send_client_callback() here, as this exception should only be thrown by endpoints that
180
- # are capable of sending callbacks.
181
- try:
182
- self.callback.ok_dialog("Notice", "You have remained idle for too long and this dialog has timed out. "
183
- "Close and re-initiate it to continue.")
184
- except SapioDialogTimeoutException:
185
- pass
186
- return SapioWebhookResult(False)
367
+ self.handle_dialog_timeout_exception_extra(e)
368
+ return self._display_exception(self.default_dialog_timeout_message,
369
+ self.default_dialog_timeout_display_type,
370
+ self.default_dialog_timeout_title)
371
+
372
+ def handle_dialog_timeout_exception_extra(self, e: SapioDialogTimeoutException) -> None:
373
+ """
374
+ An additional function that can be overridden to provide extra behavior when a SapioDialogTimeoutException is
375
+ thrown. Default behavior does nothing.
376
+ """
377
+ pass
187
378
 
188
379
  def handle_unexpected_exception(self, e: Exception) -> SapioWebhookResult:
189
380
  """
@@ -200,13 +391,21 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
200
391
  if result is not None:
201
392
  return result
202
393
  msg: str = traceback.format_exc()
203
- self.log_error(msg)
394
+ self.log_error(msg, True)
204
395
  # FR-47079: Also log all unexpected exception messages to the webhook execution log within the platform.
205
- self.log_error_to_webhook_execution_log(msg)
206
- return SapioWebhookResult(False, display_text="Unexpected error occurred during webhook execution. "
207
- "Please contact Sapio support.")
396
+ self.log_error_to_webhook_execution_log(msg, True)
397
+ self.handle_unexpected_exception_extra(e)
398
+ return self._display_exception(self.default_unexpected_error_message,
399
+ self.default_unexpected_error_display_type,
400
+ self.default_unexpected_error_title)
401
+
402
+ def handle_unexpected_exception_extra(self, e: Exception) -> None:
403
+ """
404
+ An additional function that can be overridden to provide extra behavior when a generic exception is thrown.
405
+ Default behavior does nothing.
406
+ """
407
+ pass
208
408
 
209
- # noinspection PyMethodMayBeStatic,PyUnusedLocal
210
409
  def handle_any_exception(self, e: Exception) -> SapioWebhookResult | None:
211
410
  """
212
411
  An exception handler which runs regardless of the type of exception that was raised. Can be used to "rollback"
@@ -217,7 +416,55 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
217
416
  sent by one of the normal exception handlers, or may return None if no result needs returned. If a result is
218
417
  returned, then the default behavior of other exception handlers is skipped.
219
418
  """
220
- return None
419
+ pass
420
+
421
+ def display_message(self, msg: str, display_type: MessageDisplayType, title: str = "") -> bool:
422
+ """
423
+ Display a message to the user. The form that the message takes depends on the display type.
424
+
425
+ :param msg: The message to display to the user.
426
+ :param display_type: The manner in which the message should be displayed.
427
+ :param title: If the display type is able to have a title, this is the title that will be displayed.
428
+ :return: True if the message was displayed. False if the message could not be displayed (because this
429
+ webhook can't send client callbacks).
430
+ """
431
+ if not self.can_send_client_callback():
432
+ return False
433
+ if display_type == MessageDisplayType.TOASTER_SUCCESS:
434
+ self.callback.toaster_popup(msg, title, PopupType.Success)
435
+ elif display_type == MessageDisplayType.TOASTER_INFO:
436
+ self.callback.toaster_popup(msg, title, PopupType.Info)
437
+ elif display_type == MessageDisplayType.TOASTER_WARNING:
438
+ self.callback.toaster_popup(msg, title, PopupType.Warning)
439
+ elif display_type == MessageDisplayType.TOASTER_ERROR:
440
+ self.callback.toaster_popup(msg, title, PopupType.Error)
441
+ elif display_type == MessageDisplayType.OK_DIALOG:
442
+ self.callback.ok_dialog(title, msg)
443
+ elif display_type == MessageDisplayType.DISPLAY_INFO:
444
+ self.callback.display_info(msg)
445
+ elif display_type == MessageDisplayType.DISPLAY_WARNING:
446
+ self.callback.display_warning(msg)
447
+ elif display_type == MessageDisplayType.DISPLAY_ERROR:
448
+ self.callback.display_error(msg)
449
+ return True
450
+
451
+ def _display_exception(self, msg: str, display_type: MessageDisplayType, title: str) -> SapioWebhookResult:
452
+ """
453
+ Display an exception message to the user and return a webhook result to end the webhook invocation.
454
+ This handles the cases where the webhook invocation type is incapable of sending client callbacks and must
455
+ instead return the message in the webhook result, and the case where the display type is an OK dialog, which
456
+ may potentially cause a dialog timeout exception.
457
+ """
458
+ # If the display type is an OK dialog, then we need to handle the dialog timeout exception that could be thrown.
459
+ try:
460
+ # Set the dialog timeout to something low as to not hog the connection.
461
+ self.callback.set_dialog_timeout(60)
462
+ # If this invocation type can't send client callbacks, fallback to sending the message in the result.
463
+ if self.display_message(msg, display_type, title):
464
+ return SapioWebhookResult(False)
465
+ return SapioWebhookResult(False, display_text=msg)
466
+ except SapioDialogTimeoutException:
467
+ return SapioWebhookResult(False)
221
468
 
222
469
  def log_info(self, msg: str) -> None:
223
470
  """
@@ -227,55 +474,69 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
227
474
  """
228
475
  self.logger.info(self._format_log(msg, "log_info call"))
229
476
 
230
- def log_error(self, msg: str) -> None:
477
+ def log_error(self, msg: str, is_exception: bool = False) -> None:
231
478
  """
232
479
  Write an info message to the webhook server log. Log destination is stdout. This message will include
233
480
  information about the user's group, their location in the system, the webhook invocation type, and other
234
481
  important information that can be gathered from the context that is useful for debugging.
235
482
  """
236
483
  # PR-46209: Use logger.error instead of logger.info when logging errors.
237
- self.logger.error(self._format_log(msg, "log_error call"))
484
+ self.logger.error(self._format_log(msg, "log_error call", is_exception))
238
485
 
239
- def log_error_to_webhook_execution_log(self, msg: str) -> None:
486
+ def log_error_to_webhook_execution_log(self, msg: str, is_exception: bool = False) -> None:
240
487
  """
241
488
  Write an error message to the platform's webhook execution log. This can be reviewed by navigating to the
242
489
  webhook configuration where the webhook that called this function is defined and clicking the "View Log"
243
490
  button. From there, select one of the rows for the webhook executions and click "Download Log" from the right
244
491
  side table.
245
492
  """
246
- messenger = DataMgmtServer.get_messenger(self.user)
247
- messenger.log_message(VeloxLogMessage(message=self._format_log(msg, "Error occurred during webhook execution."),
248
- log_level=VeloxLogLevel.ERROR,
249
- originating_class=self.__class__.__name__))
493
+ msg = self._format_log(msg, "Error occurred during webhook execution.", is_exception)
494
+ self.messenger.log_message(VeloxLogMessage(msg, VeloxLogLevel.ERROR, self.__class__.__name__))
250
495
 
251
- def _format_log(self, msg: str, prefix: str | None = None) -> str:
496
+ def _format_log(self, msg: str, prefix: str | None = None, is_exception: bool = False) -> str:
252
497
  """
253
498
  Given a message to log, populate it with some metadata about this particular webhook execution, including
254
499
  the group of the user and the invocation type of the webhook call.
255
500
  """
256
- # If we're able to, provide a link to the location that the error occurred at.
257
- navigator = SapioNavigationLinker(self.context)
258
- if self.context.eln_experiment is not None:
259
- link = navigator.experiment(self.context.eln_experiment)
260
- elif self.context.data_record and not self.context.data_record_list:
261
- link = navigator.data_record(self.context.data_record)
262
- elif self.context.base_data_record:
263
- link = navigator.data_record(self.context.base_data_record)
264
- else:
265
- link = None
501
+ # Start the message with the provided prefix.
502
+ message: str = prefix + "\n" if prefix else ""
266
503
 
267
- message: str = ""
268
- if prefix:
269
- message += prefix + "\n"
270
- message += f"Webhook invocation type: {self.context.end_point_type.display_name}\n"
271
- message += f"Username: {self.user.username}\n"
272
- # CR-46333: Add the user's group to the logging message.
273
- message += f"User group: {self.user.session_additional_data.current_group_name}\n"
274
- if link:
275
- message += f"User location: {link}\n"
504
+ # Construct a summary of the current state of this webhook.
505
+ message += f"{WebhookStateSummary(self, is_exception)}\n"
506
+
507
+ # End the message with the provided msg parameter.
276
508
  message += msg
277
509
  return message
278
510
 
511
+ @property
512
+ def start_time(self) -> float:
513
+ """
514
+ :return: The time that this webhook was invoked, represented in seconds. This time comes from a performance
515
+ counter and is not guaranteed to correspond to a date. Only use in comparison to other performance counters.
516
+ """
517
+ return self._start_time
518
+
519
+ @property
520
+ def start_time_millis(self) -> int:
521
+ """
522
+ :return: The epoch timestamp in milliseconds for the time that this webhook was invoked.
523
+ """
524
+ return self._start_time_epoch
525
+
526
+ def elapsed_time(self) -> float:
527
+ """
528
+ :return: The number of seconds that have elapsed since this webhook was invoked to the time that this function
529
+ is called. Measures using a performance counter to a high degree of accuracy.
530
+ """
531
+ return time.perf_counter() - self._start_time
532
+
533
+ def elapsed_time_millis(self) -> int:
534
+ """
535
+ :return: The number of milliseconds that have elapsed since this webhook was invoked to the time that this
536
+ function is called.
537
+ """
538
+ return TimeUtil.now_in_millis() - self._start_time_epoch
539
+
279
540
  def is_main_toolbar(self) -> bool:
280
541
  """
281
542
  :return: True if this endpoint was invoked as a main toolbar button.
@@ -398,3 +659,135 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
398
659
  :return: Whether client callbacks and directives can be sent from this webhook's endpoint type.
399
660
  """
400
661
  return self.context.is_client_callback_available
662
+
663
+
664
+ # FR-47390: Move the gathering of webhook information out of log_error_to_webhook_execution_log and into its own class.
665
+ class WebhookStateSummary:
666
+ """
667
+ A class that summarizes the state of a webhook at the time that it is created. This class is useful for logging
668
+ information about the webhook invocation to the execution log.
669
+ """
670
+ username: str
671
+ """The username of the user who invoked the webhook."""
672
+ user_group: str
673
+ """The group that the user is currently in."""
674
+ start_timestamp: int
675
+ """The epoch timestamp in milliseconds for when the webhook was invoked."""
676
+ start_utc_time: str
677
+ """The time that the webhook was invoked in UTC."""
678
+ start_server_time: str | None
679
+ """The time that the webhook was invoked on the server, if the TimeUtil class has a default timezone set."""
680
+ start_user_time: str
681
+ """The time that the webhook was invoked for the user, adjusted for their timezone."""
682
+ timestamp: int
683
+ """The current epoch timestamp in milliseconds."""
684
+ utc_time: str
685
+ """The current time in UTC."""
686
+ server_time: str | None
687
+ """The current time on the webhook server, if the TimeUtil class has a default timezone set."""
688
+ user_time: str
689
+ """The current time for the user, adjusted for their timezone."""
690
+ invocation_type: str
691
+ """The type of endpoint that this webhook was invoked from."""
692
+ class_name: str
693
+ """The name of the class that this webhook is an instance of."""
694
+ link: str | None
695
+ """A link to the location that the webhook was invoked from, if applicable."""
696
+ exc_summary: str | None
697
+ """A summary of the exception that occurred, if this summary is being created for an exception."""
698
+
699
+ def __init__(self, webhook: CommonsWebhookHandler, summarize_exception: bool = False):
700
+ """
701
+ :param webhook: The webhook that this summary is being created for.
702
+ :param summarize_exception: If true, then this summary will include information about the most recent exception
703
+ that occurred during the execution of the webhook.
704
+ """
705
+ # User information.
706
+ self.username = webhook.user.username
707
+ self.user_group = webhook.group_name
708
+
709
+ # Time information.
710
+ fmt: str = "%Y-%m-%d %H:%M:%S.%f"
711
+ self.start_timestamp = webhook.start_time_millis
712
+ self.start_utc_time = TimeUtil.millis_to_format(self.start_timestamp, fmt, "UTC")
713
+ self.start_server_time = None
714
+ self.start_user_time = TimeUtil.millis_to_format(self.start_timestamp, fmt, webhook.user_utc_offset_seconds)
715
+
716
+ self.timestamp = TimeUtil.now_in_millis()
717
+ self.utc_time = TimeUtil.millis_to_format(self.timestamp, fmt, "UTC")
718
+ self.server_time = None
719
+ self.user_time = TimeUtil.millis_to_format(self.timestamp, fmt, webhook.user_utc_offset_seconds)
720
+
721
+ if TimeUtil.get_default_timezone():
722
+ self.start_server_time = TimeUtil.millis_to_format(self.start_timestamp, fmt)
723
+ self.server_time = TimeUtil.millis_to_format(self.timestamp, fmt)
724
+
725
+ # Webhook invocation information.
726
+ self.invocation_type = webhook.context.end_point_type.display_name
727
+ self.class_name = webhook.__class__.__name__
728
+
729
+ # User location information.
730
+ self.link = None
731
+ context = webhook.context
732
+ navigator = SapioNavigationLinker(context)
733
+ if context.eln_experiment is not None:
734
+ self.link = navigator.experiment(context.eln_experiment)
735
+ elif context.base_data_record:
736
+ self.link = navigator.data_record(context.base_data_record)
737
+ elif context.data_record and not context.data_record_list:
738
+ self.link = navigator.data_record(context.data_record)
739
+
740
+ # If this is logging an exception, get the system's info on the most recent exception and produce a summary.
741
+ self.exc_summary = None
742
+ if summarize_exception:
743
+ exc_type, exc_value, exc_traceback = sys.exc_info()
744
+
745
+ # If this is a SapioServerException, then it can contain json with the exception message from the server.
746
+ # This provides a more useful summary than args[0] does, so use it instead.
747
+ exception_name: str = exc_type.__name__
748
+ if exception_name == "SapioServerException":
749
+ # Sometimes the SapioServerException is HTML instead of JSON. If that's the case, just try/catch the
750
+ # failure to parse the JSON and continue to use args[0] as the exception message.
751
+ try:
752
+ exc_str: str = str(exc_value)
753
+ exception_msg = json.loads(exc_str[exc_str.find("{"):]).get("message")
754
+ except Exception:
755
+ exception_msg = exc_value.args[0]
756
+ else:
757
+ # For all other exceptions, assume that the first argument is a message.
758
+ exception_msg = exc_value.args[0]
759
+
760
+ self.exc_summary = f"{exception_name}: {exception_msg}"
761
+ del (exc_type, exc_value, exc_traceback)
762
+
763
+ def __str__(self) -> str:
764
+ message: str = ""
765
+
766
+ # Record the time that the webhook was started and this state was created.
767
+ message += "Webhook Invocation Time:\n"
768
+ message += f"\tUTC: {self.start_utc_time}\n"
769
+ if TimeUtil.get_default_timezone() is not None:
770
+ message += f"\tServer: {self.start_server_time}\n"
771
+ message += f"\tUser: {self.start_user_time}\n"
772
+
773
+ message += "Current Time:\n"
774
+ message += f"\tUTC: {self.utc_time}\n"
775
+ if TimeUtil.get_default_timezone() is not None:
776
+ message += f"\tServer: {self.server_time}\n"
777
+ message += f"\tUser: {self.user_time}\n"
778
+
779
+ # Record information about the user and how the webhook was invoked.
780
+ message += f"Username: {self.username}\n"
781
+ message += f"User group: {self.user_group}\n"
782
+ message += f"Webhook invocation type: {self.invocation_type}\n"
783
+ message += f"Class name: {self.class_name}\n"
784
+
785
+ # If we're able to, provide a link to the location that the error occurred at.
786
+ if self.link:
787
+ message += f"User location: {self.link}\n"
788
+
789
+ # If this state summary is for an exception, include the exception summary.
790
+ if self.exc_summary:
791
+ message += f"{self.exc_summary}\n"
792
+
793
+ return message
@@ -3,7 +3,7 @@ import traceback
3
3
  from abc import abstractmethod, ABC
4
4
  from base64 import b64decode
5
5
  from logging import Logger
6
- from typing import Any
6
+ from typing import Any, Mapping
7
7
 
8
8
  from flask import request, Response, Request
9
9
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
@@ -122,7 +122,7 @@ class AbstractWebserviceHandler(AbstractWebhookHandler):
122
122
  """
123
123
  pass
124
124
 
125
- def authenticate_user(self, headers: dict[str, str]) -> SapioUser:
125
+ def authenticate_user(self, headers: Mapping[str, str]) -> SapioUser:
126
126
  """
127
127
  Authenticate a user for making requests to a Sapio server using the provided headers. If no user can be
128
128
  authenticated, then an exception will be thrown.