sapiopycommons 2024.3.18a156__py3-none-any.whl → 2025.1.17a402__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 (52) hide show
  1. sapiopycommons/callbacks/__init__.py +0 -0
  2. sapiopycommons/callbacks/callback_util.py +2041 -0
  3. sapiopycommons/callbacks/field_builder.py +545 -0
  4. sapiopycommons/chem/IndigoMolecules.py +52 -5
  5. sapiopycommons/chem/Molecules.py +114 -30
  6. sapiopycommons/customreport/__init__.py +0 -0
  7. sapiopycommons/customreport/column_builder.py +60 -0
  8. sapiopycommons/customreport/custom_report_builder.py +137 -0
  9. sapiopycommons/customreport/term_builder.py +315 -0
  10. sapiopycommons/datatype/attachment_util.py +17 -15
  11. sapiopycommons/datatype/data_fields.py +61 -0
  12. sapiopycommons/datatype/pseudo_data_types.py +440 -0
  13. sapiopycommons/eln/experiment_handler.py +390 -90
  14. sapiopycommons/eln/experiment_report_util.py +649 -0
  15. sapiopycommons/eln/plate_designer.py +152 -0
  16. sapiopycommons/files/complex_data_loader.py +31 -0
  17. sapiopycommons/files/file_bridge.py +153 -25
  18. sapiopycommons/files/file_bridge_handler.py +555 -0
  19. sapiopycommons/files/file_data_handler.py +633 -0
  20. sapiopycommons/files/file_util.py +270 -158
  21. sapiopycommons/files/file_validator.py +569 -0
  22. sapiopycommons/files/file_writer.py +377 -0
  23. sapiopycommons/flowcyto/flow_cyto.py +77 -0
  24. sapiopycommons/flowcyto/flowcyto_data.py +75 -0
  25. sapiopycommons/general/accession_service.py +375 -0
  26. sapiopycommons/general/aliases.py +259 -18
  27. sapiopycommons/general/audit_log.py +185 -0
  28. sapiopycommons/general/custom_report_util.py +252 -31
  29. sapiopycommons/general/directive_util.py +86 -0
  30. sapiopycommons/general/exceptions.py +69 -7
  31. sapiopycommons/general/popup_util.py +85 -18
  32. sapiopycommons/general/sapio_links.py +50 -0
  33. sapiopycommons/general/storage_util.py +148 -0
  34. sapiopycommons/general/time_util.py +97 -7
  35. sapiopycommons/multimodal/multimodal.py +146 -0
  36. sapiopycommons/multimodal/multimodal_data.py +490 -0
  37. sapiopycommons/processtracking/__init__.py +0 -0
  38. sapiopycommons/processtracking/custom_workflow_handler.py +406 -0
  39. sapiopycommons/processtracking/endpoints.py +192 -0
  40. sapiopycommons/recordmodel/record_handler.py +653 -149
  41. sapiopycommons/rules/eln_rule_handler.py +89 -8
  42. sapiopycommons/rules/on_save_rule_handler.py +89 -12
  43. sapiopycommons/sftpconnect/__init__.py +0 -0
  44. sapiopycommons/sftpconnect/sftp_builder.py +70 -0
  45. sapiopycommons/webhook/webhook_context.py +39 -0
  46. sapiopycommons/webhook/webhook_handlers.py +617 -69
  47. sapiopycommons/webhook/webservice_handlers.py +317 -0
  48. {sapiopycommons-2024.3.18a156.dist-info → sapiopycommons-2025.1.17a402.dist-info}/METADATA +5 -4
  49. sapiopycommons-2025.1.17a402.dist-info/RECORD +60 -0
  50. {sapiopycommons-2024.3.18a156.dist-info → sapiopycommons-2025.1.17a402.dist-info}/WHEEL +1 -1
  51. sapiopycommons-2024.3.18a156.dist-info/RECORD +0 -28
  52. {sapiopycommons-2024.3.18a156.dist-info → sapiopycommons-2025.1.17a402.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +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
14
+ from sapiopylib.rest.DataMgmtService import DataMgmtServer
5
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
22
+ from sapiopylib.rest.User import SapioUser
6
23
  from sapiopylib.rest.WebhookService import AbstractWebhookHandler
7
- from sapiopylib.rest.pojo.webhook.ClientCallbackResult import FormEntryDialogResult
24
+ from sapiopylib.rest.pojo.Message import VeloxLogMessage, VeloxLogLevel
25
+ from sapiopylib.rest.pojo.webhook.ClientCallbackRequest import PopupType
8
26
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
9
27
  from sapiopylib.rest.pojo.webhook.WebhookEnums import WebhookEndpointType
10
28
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
29
+ from sapiopylib.rest.utils.DataTypeCacheManager import DataTypeCacheManager
30
+ from sapiopylib.rest.utils.FoundationAccessioning import FoundationAccessionManager
11
31
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
12
32
  RecordModelRelationshipManager
13
33
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
34
+ from sapiopylib.rest.utils.recordmodel.last_saved import LastSavedValueManager
14
35
 
15
- from sapiopycommons.general.exceptions import SapioUserErrorException, SapioCriticalErrorException
16
- from sapiopycommons.general.popup_util import PopupUtil
36
+ from sapiopycommons.callbacks.callback_util import CallbackUtil
37
+ from sapiopycommons.eln.experiment_handler import ExperimentHandler
38
+ from sapiopycommons.general.directive_util import DirectiveUtil
39
+ from sapiopycommons.general.exceptions import SapioUserErrorException, SapioCriticalErrorException, \
40
+ SapioUserCancelledException, SapioException, SapioDialogTimeoutException, MessageDisplayType
41
+ from sapiopycommons.general.sapio_links import SapioNavigationLinker
42
+ from sapiopycommons.general.time_util import TimeUtil
43
+ from sapiopycommons.recordmodel.record_handler import RecordHandler
44
+ from sapiopycommons.rules.eln_rule_handler import ElnRuleHandler
45
+ from sapiopycommons.rules.on_save_rule_handler import OnSaveRuleHandler
46
+ from sapiopycommons.webhook.webhook_context import ProcessQueueContext
17
47
 
18
48
 
19
49
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
@@ -25,41 +55,213 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
25
55
  """
26
56
  logger: Logger
27
57
 
58
+ _start_time: float
59
+ _start_time_epoch: int
60
+
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."""
28
68
  context: SapioWebhookContext
69
+ """The context from the server of this webhook's invocation."""
29
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
+ custom_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."""
30
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
+
31
101
  rec_man: RecordModelManager
102
+ """The record model manager. Used for committing record model changes to the system."""
32
103
  inst_man: RecordModelInstanceManager
104
+ """The record model instance manager. Used for adding record models to the cache."""
33
105
  rel_man: RecordModelRelationshipManager
106
+ """The record model relationship manager. Used for loading parent/child and side-link relationships between record
107
+ models."""
34
108
  # FR-46329: Add the ancestor manager to CommonsWebhookHandler.
35
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."""
113
+
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. """
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."""
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."""
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. """
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."""
36
163
 
37
164
  def run(self, context: SapioWebhookContext) -> SapioWebhookResult:
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.
38
170
  self.context = context
39
- self.logger = context.user.logger
40
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.
178
+ self.logger = self.user.logger
179
+
180
+ # Initialize basic manager classes from sapiopylib.
181
+ self.acc_man = DataMgmtServer.get_accession_manager(self.user)
182
+ self.custom_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)
41
185
  self.dr_man = context.data_record_manager
42
- self.rec_man = RecordModelManager(context.user)
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.
196
+ self.rec_man = RecordModelManager(self.user)
43
197
  self.inst_man = self.rec_man.instance_manager
44
198
  self.rel_man = self.rec_man.relationship_manager
45
- 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
201
+
202
+ # Initialize more complex classes from sapiopylib and sapiopycommons.
203
+ self.fnd_acc_man = FoundationAccessionManager(self.user)
204
+ self.dt_cache = DataTypeCacheManager(self.user)
205
+ self.rec_handler = RecordHandler(context)
206
+ self.callback = CallbackUtil(context)
207
+ self.directive = DirectiveUtil(context)
208
+ if context.eln_experiment is not None:
209
+ self.exp_handler = ExperimentHandler(context)
210
+ else:
211
+ self.exp_handler = None
212
+ if self.is_on_save_rule():
213
+ self.rule_handler = OnSaveRuleHandler(context)
214
+ elif self.is_eln_rule():
215
+ self.rule_handler = ElnRuleHandler(context)
216
+ else:
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
+ # Set the default display types, titles, and messages for each type of exception that can display a message.
224
+ self.default_user_error_display_type = MessageDisplayType.TOASTER_WARNING
225
+ self.default_critical_error_display_type = MessageDisplayType.DISPLAY_ERROR
226
+ self.default_dialog_timeout_display_type = MessageDisplayType.OK_DIALOG
227
+ self.default_unexpected_error_display_type = MessageDisplayType.TOASTER_WARNING
46
228
 
47
- # Handle any client callback results that may have been sent due to an exception.
48
- exception_result: SapioWebhookResult | None = self.handle_exception_callback()
49
- if exception_result is not None:
50
- return exception_result
229
+ self.default_user_error_title = ""
230
+ self.default_critical_error_title = ""
231
+ self.default_dialog_timeout_title = "Dialog Timeout"
232
+ self.default_unexpected_error_title = ""
233
+
234
+ self.default_dialog_timeout_message = ("You have remained idle for too long and this dialog has timed out. "
235
+ "Close and re-initiate it to continue.")
236
+ self.default_unexpected_error_message = ("Unexpected error occurred during webhook execution. Please contact "
237
+ "Sapio support.")
51
238
 
52
239
  # Wrap the execution of each webhook in a try/catch. If an exception occurs, handle any special sapiopycommons
53
240
  # exceptions. Otherwise, return a generic message stating that an error occurred.
54
241
  try:
55
- return self.execute(context)
242
+ self.initialize(context)
243
+ result = self.execute(context)
244
+ if result is None:
245
+ raise SapioException("Your execute function returned a None result! Don't forget your return statement!")
246
+ return result
56
247
  except SapioUserErrorException as e:
57
248
  return self.handle_user_error_exception(e)
58
249
  except SapioCriticalErrorException as e:
59
250
  return self.handle_critical_error_exception(e)
251
+ except SapioUserCancelledException as e:
252
+ return self.handle_user_cancelled_exception(e)
253
+ except SapioDialogTimeoutException as e:
254
+ return self.handle_dialog_timeout_exception(e)
60
255
  except Exception as e:
61
256
  return self.handle_unexpected_exception(e)
62
257
 
258
+ def initialize(self, context: SapioWebhookContext) -> None:
259
+ """
260
+ A function that can be optionally overridden by your webhooks to initialize additional instance variables,
261
+ or set up whatever else you wish to set up before the execute function is ran. Default behavior does nothing.
262
+ """
263
+ pass
264
+
63
265
  @abstractmethod
64
266
  def execute(self, context: SapioWebhookContext) -> SapioWebhookResult:
65
267
  """
@@ -69,102 +271,268 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
69
271
 
70
272
  # CR-46153: Make CommonsWebhookHandler exception handling more easily overridable by splitting them out of
71
273
  # the run method and into their own functions.
72
- def handle_exception_callback(self) -> SapioWebhookResult | None:
274
+ def handle_user_error_exception(self, e: SapioUserErrorException) -> SapioWebhookResult:
73
275
  """
74
- Handle any client callbacks made as the result of exceptions. Whether a result is from an exception is
75
- determined by reading the callback context data. Context data for exceptions should be something unique that
76
- no programmer would use in practice so that we don't accidentally handle callbacks we shouldn't.
77
- :return: A SapioWebhookResult for any handled exceptions. None if nothing was handled.
276
+ Handle a SapioUserErrorException.
277
+
278
+ Default behavior returns a false result and the error message as display text in a webhook result.
279
+
280
+ :param e: The exception that was raised.
281
+ :return: A SapioWebhookResult to end the webhook session with.
78
282
  """
79
- # Catch any SapioCriticalErrorException results.
80
- result = self.context.client_callback_result
283
+ result: SapioWebhookResult | None = self.handle_any_exception(e)
81
284
  if result is not None:
82
- result_context = result.callback_context_data
83
- if isinstance(result, FormEntryDialogResult) and result_context == "SapioCriticalErrorException":
84
- return SapioWebhookResult(False)
85
- return None
285
+ return result
286
+ self.log_error(traceback.format_exc())
287
+ self.handle_user_error_exception_extra(e)
86
288
 
87
- def handle_user_error_exception(self, e: SapioUserErrorException) -> SapioWebhookResult:
289
+ display_type: MessageDisplayType = e.display_type if e.display_type else self.default_user_error_display_type
290
+ title: str = e.title if e.title is not None else self.default_user_error_title
291
+ return self._display_exception(e.msg, display_type, title)
292
+
293
+ def handle_user_error_exception_extra(self, e: SapioUserErrorException) -> None:
88
294
  """
89
- Handle a SapioUserErrorException. Default behavior returns the error message as display text in a webhook
90
- result.
295
+ An additional function that can be overridden to provide extra behavior when a SapioUserErrorException is thrown.
296
+ Default behavior does nothing.
297
+ """
298
+ pass
299
+
300
+ def handle_critical_error_exception(self, e: SapioCriticalErrorException) -> SapioWebhookResult:
301
+ """
302
+ Handle a SapioCriticalErrorException.
303
+
304
+ Default behavior makes a display_error client callback with the error message and returns a false result.
305
+
91
306
  :param e: The exception that was raised.
92
- :return: A SapioWebhookResult reporting the exception to the user.
307
+ :return: A SapioWebhookResult to end the webhook session with.
93
308
  """
94
309
  result: SapioWebhookResult | None = self.handle_any_exception(e)
95
310
  if result is not None:
96
311
  return result
97
312
  self.log_error(traceback.format_exc())
98
- return SapioWebhookResult(False, display_text=e.args[0])
313
+ self.handle_critical_error_exception_extra(e)
99
314
 
100
- def handle_critical_error_exception(self, e: SapioCriticalErrorException) -> SapioWebhookResult:
315
+ display_type: MessageDisplayType = e.display_type if e.display_type else self.default_critical_error_display_type
316
+ title: str = e.title if e.title is not None else self.default_critical_error_title
317
+ return self._display_exception(e.msg, display_type, title)
318
+
319
+ def handle_critical_error_exception_extra(self, e: SapioCriticalErrorException) -> None:
101
320
  """
102
- Handle a SapioCriticalErrorException. Default behavior returns a FormEntryDialogRequest with an uneditable
103
- text entry containing the error message. This client callback for this request is handled by
104
- handle_exception_callback.
321
+ An additional function that can be overridden to provide extra behavior when a SapioCriticalErrorException is
322
+ thrown. Default behavior does nothing.
323
+ """
324
+ pass
325
+
326
+ def handle_user_cancelled_exception(self, e: SapioUserCancelledException) -> SapioWebhookResult:
327
+ """
328
+ Handle a SapioUserCancelledException.
329
+
330
+ Default behavior simply ends the webhook session with a true result (since the user cancelling is a valid
331
+ action).
332
+
105
333
  :param e: The exception that was raised.
106
- :return: A SapioWebhookResult reporting the exception to the user.
334
+ :return: A SapioWebhookResult to end the webhook session with.
107
335
  """
108
336
  result: SapioWebhookResult | None = self.handle_any_exception(e)
109
337
  if result is not None:
110
338
  return result
111
- self.log_error(traceback.format_exc())
112
- return PopupUtil.string_field_popup("Error", "", "Reason", e.args[0],
113
- request_context="SapioCriticalErrorException", num_lines=10)
339
+ self.handle_user_cancelled_exception_extra(e)
340
+ # FR-47390: Return a False result for user cancelled exceptions so that transactional webhooks cancel the
341
+ # commit.
342
+ return SapioWebhookResult(False)
343
+
344
+ def handle_user_cancelled_exception_extra(self, e: SapioUserCancelledException) -> None:
345
+ """
346
+ An additional function that can be overridden to provide extra behavior when a SapioUserCancelledException is
347
+ thrown. Default behavior does nothing.
348
+ """
349
+ pass
350
+
351
+ def handle_dialog_timeout_exception(self, e: SapioDialogTimeoutException) -> SapioWebhookResult:
352
+ """
353
+ Handle a SapioDialogTimeoutException.
354
+
355
+ Default behavior displays an OK popup notifying the user that the dialog has timed out and returns a false
356
+ webhook result.
357
+
358
+ :param e: The exception that was raised.
359
+ :return: A SapioWebhookResult to end the webhook session with.
360
+ """
361
+ result: SapioWebhookResult | None = self.handle_any_exception(e)
362
+ if result is not None:
363
+ return result
364
+ self.handle_dialog_timeout_exception_extra(e)
365
+ return self._display_exception(self.default_dialog_timeout_message,
366
+ self.default_dialog_timeout_display_type,
367
+ self.default_dialog_timeout_title)
368
+
369
+ def handle_dialog_timeout_exception_extra(self, e: SapioDialogTimeoutException) -> None:
370
+ """
371
+ An additional function that can be overridden to provide extra behavior when a SapioDialogTimeoutException is
372
+ thrown. Default behavior does nothing.
373
+ """
374
+ pass
114
375
 
115
376
  def handle_unexpected_exception(self, e: Exception) -> SapioWebhookResult:
116
377
  """
117
- Handle a generic exception which isn't a SapioUserErrorException or SapioCriticalErrorException. Default
118
- behavior returns a generic error message as display text informing the user to contact Sapio support.
378
+ Handle a generic exception which isn't one of the handled Sapio exceptions.
379
+
380
+ Default behavior returns a false webhook result with a generic error message as display text informing the user
381
+ to contact Sapio support. Additionally, the stack trace of the exception that was thrown is logged to the
382
+ execution log for the webhook call in the system.
383
+
119
384
  :param e: The exception that was raised.
120
- :return: A SapioWebhookResult reporting the exception to the user.
385
+ :return: A SapioWebhookResult to end the webhook session with.
121
386
  """
122
387
  result: SapioWebhookResult | None = self.handle_any_exception(e)
123
388
  if result is not None:
124
389
  return result
125
- self.log_error(traceback.format_exc())
126
- return SapioWebhookResult(False, display_text="Unexpected error occurred during webhook execution. "
127
- "Please contact Sapio support.")
390
+ msg: str = traceback.format_exc()
391
+ self.log_error(msg, True)
392
+ # FR-47079: Also log all unexpected exception messages to the webhook execution log within the platform.
393
+ self.log_error_to_webhook_execution_log(msg, True)
394
+ self.handle_unexpected_exception_extra(e)
395
+ return self._display_exception(self.default_unexpected_error_message,
396
+ self.default_unexpected_error_display_type,
397
+ self.default_unexpected_error_title)
398
+
399
+ def handle_unexpected_exception_extra(self, e: Exception) -> None:
400
+ """
401
+ An additional function that can be overridden to provide extra behavior when a generic exception is thrown.
402
+ Default behavior does nothing.
403
+ """
404
+ pass
128
405
 
129
- # noinspection PyMethodMayBeStatic,PyUnusedLocal
130
406
  def handle_any_exception(self, e: Exception) -> SapioWebhookResult | None:
131
407
  """
132
408
  An exception handler which runs regardless of the type of exception that was raised. Can be used to "rollback"
133
409
  the client if an error occurs. Default behavior does nothing and returns None.
410
+
134
411
  :param e: The exception that was raised.
135
412
  :return: An optional SapioWebhookResult. May return a custom message to the client that wouldn't have been
136
- sent by one of the normal exception handlers, or may return None if no result needs returned.
413
+ sent by one of the normal exception handlers, or may return None if no result needs returned. If a result is
414
+ returned, then the default behavior of other exception handlers is skipped.
137
415
  """
138
- return None
416
+ pass
417
+
418
+ def display_message(self, msg: str, display_type: MessageDisplayType, title: str = "") -> bool:
419
+ """
420
+ Display a message to the user. The form that the message takes depends on the display type.
421
+
422
+ :param msg: The message to display to the user.
423
+ :param display_type: The manner in which the message should be displayed.
424
+ :param title: If the display type is able to have a title, this is the title that will be displayed.
425
+ :return: True if the message was displayed. False if the message could not be displayed (because this
426
+ webhook can't send client callbacks).
427
+ """
428
+ if not self.can_send_client_callback():
429
+ return False
430
+ if display_type == MessageDisplayType.TOASTER_SUCCESS:
431
+ self.callback.toaster_popup(msg, title, PopupType.Success)
432
+ elif display_type == MessageDisplayType.TOASTER_INFO:
433
+ self.callback.toaster_popup(msg, title, PopupType.Info)
434
+ elif display_type == MessageDisplayType.TOASTER_WARNING:
435
+ self.callback.toaster_popup(msg, title, PopupType.Warning)
436
+ elif display_type == MessageDisplayType.TOASTER_ERROR:
437
+ self.callback.toaster_popup(msg, title, PopupType.Error)
438
+ elif display_type == MessageDisplayType.OK_DIALOG:
439
+ self.callback.ok_dialog(title, msg)
440
+ elif display_type == MessageDisplayType.DISPLAY_INFO:
441
+ self.callback.display_info(msg)
442
+ elif display_type == MessageDisplayType.DISPLAY_WARNING:
443
+ self.callback.display_warning(msg)
444
+ elif display_type == MessageDisplayType.DISPLAY_ERROR:
445
+ self.callback.display_error(msg)
446
+ return True
447
+
448
+ def _display_exception(self, msg: str, display_type: MessageDisplayType, title: str) -> SapioWebhookResult:
449
+ """
450
+ Display an exception message to the user and return a webhook result to end the webhook invocation.
451
+ This handles the cases where the webhook invocation type is incapable of sending client callbacks and must
452
+ instead return the message in the webhook result, and the case where the display type is an OK dialog, which
453
+ may potentially cause a dialog timeout exception.
454
+ """
455
+ # If the display type is an OK dialog, then we need to handle the dialog timeout exception that could be thrown.
456
+ try:
457
+ # Set the dialog timeout to something low as to not hog the connection.
458
+ self.callback.set_dialog_timeout(60)
459
+ # If this invocation type can't send client callbacks, fallback to sending the message in the result.
460
+ if self.display_message(msg, display_type, title):
461
+ return SapioWebhookResult(False)
462
+ return SapioWebhookResult(False, display_text=msg)
463
+ except SapioDialogTimeoutException:
464
+ return SapioWebhookResult(False)
139
465
 
140
466
  def log_info(self, msg: str) -> None:
141
467
  """
142
- Write an info message to the log. Log destination is stdout. This message will be prepended with the user's
143
- username and the experiment ID of the experiment they are in, if any.
144
- """
145
- exp_id = None
146
- if self.context.eln_experiment is not None:
147
- exp_id = self.context.eln_experiment.notebook_experiment_id
148
- # CR-46333: Add the user's group to the logging message.
149
- user = self.context.user
150
- username = user.username
151
- group_name = user.session_additional_data.current_group_name
152
- self.logger.info(f"(User: {username}, Group: {group_name}, Experiment: {exp_id}):\n{msg}")
153
-
154
- def log_error(self, msg: str) -> None:
155
- """
156
- Write an error message to the log. Log destination is stderr. This message will be prepended with the user's
157
- username and the experiment ID of the experiment they are in, if any.
158
- """
159
- exp_id = None
160
- if self.context.eln_experiment is not None:
161
- exp_id = self.context.eln_experiment.notebook_experiment_id
162
- # CR-46333: Add the user's group to the logging message.
163
- user = self.context.user
164
- username = user.username
165
- group_name = user.session_additional_data.current_group_name
468
+ Write an info message to the webhook server log. Log destination is stdout. This message will include
469
+ information about the user's group, their location in the system, the webhook invocation type, and other
470
+ important information that can be gathered from the context that is useful for debugging.
471
+ """
472
+ self.logger.info(self._format_log(msg, "log_info call"))
473
+
474
+ def log_error(self, msg: str, is_exception: bool = False) -> None:
475
+ """
476
+ Write an info message to the webhook server log. Log destination is stdout. This message will include
477
+ information about the user's group, their location in the system, the webhook invocation type, and other
478
+ important information that can be gathered from the context that is useful for debugging.
479
+ """
166
480
  # PR-46209: Use logger.error instead of logger.info when logging errors.
167
- self.logger.error(f"(User: {username}, Group: {group_name}, Experiment: {exp_id}):\n{msg}")
481
+ self.logger.error(self._format_log(msg, "log_error call", is_exception))
482
+
483
+ def log_error_to_webhook_execution_log(self, msg: str, is_exception: bool = False) -> None:
484
+ """
485
+ Write an error message to the platform's webhook execution log. This can be reviewed by navigating to the
486
+ webhook configuration where the webhook that called this function is defined and clicking the "View Log"
487
+ button. From there, select one of the rows for the webhook executions and click "Download Log" from the right
488
+ side table.
489
+ """
490
+ msg = self._format_log(msg, "Error occurred during webhook execution.", is_exception)
491
+ self.messenger.log_message(VeloxLogMessage(msg, VeloxLogLevel.ERROR, self.__class__.__name__))
492
+
493
+ def _format_log(self, msg: str, prefix: str | None = None, is_exception: bool = False) -> str:
494
+ """
495
+ Given a message to log, populate it with some metadata about this particular webhook execution, including
496
+ the group of the user and the invocation type of the webhook call.
497
+ """
498
+ # Start the message with the provided prefix.
499
+ message: str = prefix + "\n" if prefix else ""
500
+
501
+ # Construct a summary of the current state of this webhook.
502
+ message += f"{WebhookStateSummary(self, is_exception)}\n"
503
+
504
+ # End the message with the provided msg parameter.
505
+ message += msg
506
+ return message
507
+
508
+ @property
509
+ def start_time(self) -> float:
510
+ """
511
+ :return: The time that this webhook was invoked, represented in seconds. This time comes from a performance
512
+ counter and is not guaranteed to correspond to a date. Only use in comparison to other performance counters.
513
+ """
514
+ return self._start_time
515
+
516
+ @property
517
+ def start_time_millis(self) -> int:
518
+ """
519
+ :return: The epoch timestamp in milliseconds for the time that this webhook was invoked.
520
+ """
521
+ return self._start_time_epoch
522
+
523
+ def elapsed_time(self) -> float:
524
+ """
525
+ :return: The number of seconds that have elapsed since this webhook was invoked to the time that this function
526
+ is called. Measures using a performance counter to a high degree of accuracy.
527
+ """
528
+ return time.perf_counter() - self._start_time
529
+
530
+ def elapsed_time_millis(self) -> int:
531
+ """
532
+ :return: The number of milliseconds that have elapsed since this webhook was invoked to the time that this
533
+ function is called.
534
+ """
535
+ return TimeUtil.now_in_millis() - self._start_time_epoch
168
536
 
169
537
  def is_main_toolbar(self) -> bool:
170
538
  """
@@ -240,3 +608,183 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
240
608
  :return: True if this endpoint was invoked as a scheduled action.
241
609
  """
242
610
  return self.context.end_point_type == WebhookEndpointType.SCHEDULEDPLUGIN
611
+
612
+ def is_action_button_field(self) -> bool:
613
+ """
614
+ :return: True if this endpoint was invoked as an action button field.
615
+ """
616
+ return self.context.end_point_type == WebhookEndpointType.ACTIONDATAFIELD
617
+
618
+ def is_action_text_field(self) -> bool:
619
+ """
620
+ :return: True if this endpoint was invoked as an action text field.
621
+ """
622
+ return self.context.end_point_type == WebhookEndpointType.ACTION_TEXT_FIELD
623
+
624
+ def is_custom(self) -> bool:
625
+ """
626
+ :return: True if this endpoint was invoked from a custom point, such as a custom queue.
627
+ """
628
+ return self.context.end_point_type == WebhookEndpointType.CUSTOM
629
+
630
+ def is_calendar_event_click_handler(self) -> bool:
631
+ """
632
+ :return: True if this endpoint was invoked from a calendar event click handler.
633
+ """
634
+ return self.context.end_point_type == WebhookEndpointType.CALENDAR_EVENT_CLICK_HANDLER
635
+
636
+ def is_eln_menu_grabber(self) -> bool:
637
+ """
638
+ :return: True if this endpoint was invoked as a notebook entry grabber.
639
+ """
640
+ return self.context.end_point_type == WebhookEndpointType.NOTEBOOKEXPERIMENTGRABBER
641
+
642
+ def is_conversation_bot(self) -> bool:
643
+ """
644
+ :return: True if this endpoint was invoked as from a conversation bot.
645
+ """
646
+ return self.context.end_point_type == WebhookEndpointType.CONVERSATION_BOT
647
+
648
+ def is_multi_data_type_table_toolbar(self) -> bool:
649
+ """
650
+ :return: True if this endpoint was invoked as a multi data type table toolbar button.
651
+ """
652
+ return self.context.end_point_type == WebhookEndpointType.REPORTTOOLBAR
653
+
654
+ def can_send_client_callback(self) -> bool:
655
+ """
656
+ :return: Whether client callbacks and directives can be sent from this webhook's endpoint type.
657
+ """
658
+ return self.context.is_client_callback_available
659
+
660
+
661
+ # FR-47390: Move the gathering of webhook information out of log_error_to_webhook_execution_log and into its own class.
662
+ class WebhookStateSummary:
663
+ """
664
+ A class that summarizes the state of a webhook at the time that it is created. This class is useful for logging
665
+ information about the webhook invocation to the execution log.
666
+ """
667
+ username: str
668
+ """The username of the user who invoked the webhook."""
669
+ user_group: str
670
+ """The group that the user is currently in."""
671
+ start_timestamp: int
672
+ """The epoch timestamp in milliseconds for when the webhook was invoked."""
673
+ start_utc_time: str
674
+ """The time that the webhook was invoked in UTC."""
675
+ start_server_time: str | None
676
+ """The time that the webhook was invoked on the server, if the TimeUtil class has a default timezone set."""
677
+ start_user_time: str
678
+ """The time that the webhook was invoked for the user, adjusted for their timezone."""
679
+ timestamp: int
680
+ """The current epoch timestamp in milliseconds."""
681
+ utc_time: str
682
+ """The current time in UTC."""
683
+ server_time: str | None
684
+ """The current time on the webhook server, if the TimeUtil class has a default timezone set."""
685
+ user_time: str
686
+ """The current time for the user, adjusted for their timezone."""
687
+ invocation_type: str
688
+ """The type of endpoint that this webhook was invoked from."""
689
+ class_name: str
690
+ """The name of the class that this webhook is an instance of."""
691
+ link: str | None
692
+ """A link to the location that the webhook was invoked from, if applicable."""
693
+ exc_summary: str | None
694
+ """A summary of the exception that occurred, if this summary is being created for an exception."""
695
+
696
+ def __init__(self, webhook: CommonsWebhookHandler, summarize_exception: bool = False):
697
+ """
698
+ :param webhook: The webhook that this summary is being created for.
699
+ :param summarize_exception: If true, then this summary will include information about the most recent exception
700
+ that occurred during the execution of the webhook.
701
+ """
702
+ # User information.
703
+ self.username = webhook.user.username
704
+ self.user_group = webhook.group_name
705
+
706
+ # Time information.
707
+ fmt: str = "%Y-%m-%d %H:%M:%S.%f"
708
+ self.start_timestamp = webhook.start_time_millis
709
+ self.start_utc_time = TimeUtil.millis_to_format(self.start_timestamp, fmt, "UTC")
710
+ self.start_server_time = None
711
+ self.start_user_time = TimeUtil.millis_to_format(self.start_timestamp, fmt, webhook.user_utc_offset_seconds)
712
+
713
+ self.timestamp = TimeUtil.now_in_millis()
714
+ self.utc_time = TimeUtil.millis_to_format(self.timestamp, fmt, "UTC")
715
+ self.server_time = None
716
+ self.user_time = TimeUtil.millis_to_format(self.timestamp, fmt, webhook.user_utc_offset_seconds)
717
+
718
+ if TimeUtil.get_default_timezone():
719
+ self.start_server_time = TimeUtil.millis_to_format(self.start_timestamp, fmt)
720
+ self.server_time = TimeUtil.millis_to_format(self.timestamp, fmt)
721
+
722
+ # Webhook invocation information.
723
+ self.invocation_type = webhook.context.end_point_type.display_name
724
+ self.class_name = webhook.__class__.__name__
725
+
726
+ # User location information.
727
+ self.link = None
728
+ context = webhook.context
729
+ navigator = SapioNavigationLinker(context)
730
+ if context.eln_experiment is not None:
731
+ self.link = navigator.experiment(context.eln_experiment)
732
+ elif context.base_data_record:
733
+ self.link = navigator.data_record(context.base_data_record)
734
+ elif context.data_record and not context.data_record_list:
735
+ self.link = navigator.data_record(context.data_record)
736
+
737
+ # If this is logging an exception, get the system's info on the most recent exception and produce a summary.
738
+ self.exc_summary = None
739
+ if summarize_exception:
740
+ exc_type, exc_value, exc_traceback = sys.exc_info()
741
+
742
+ # If this is a SapioServerException, then it can contain json with the exception message from the server.
743
+ # This provides a more useful summary than args[0] does, so use it instead.
744
+ exception_name: str = exc_type.__name__
745
+ if exception_name == "SapioServerException":
746
+ # Sometimes the SapioServerException is HTML instead of JSON. If that's the case, just try/catch the
747
+ # failure to parse the JSON and continue to use args[0] as the exception message.
748
+ try:
749
+ exc_str: str = str(exc_value)
750
+ exception_msg = json.loads(exc_str[exc_str.find("{"):]).get("message")
751
+ except Exception:
752
+ exception_msg = exc_value.args[0]
753
+ else:
754
+ # For all other exceptions, assume that the first argument is a message.
755
+ exception_msg = exc_value.args[0]
756
+
757
+ self.exc_summary = f"{exception_name}: {exception_msg}"
758
+ del (exc_type, exc_value, exc_traceback)
759
+
760
+ def __str__(self) -> str:
761
+ message: str = ""
762
+
763
+ # Record the time that the webhook was started and this state was created.
764
+ message += "Webhook Invocation Time:\n"
765
+ message += f"\tUTC: {self.start_utc_time}\n"
766
+ if TimeUtil.get_default_timezone() is not None:
767
+ message += f"\tServer: {self.start_server_time}\n"
768
+ message += f"\tUser: {self.start_user_time}\n"
769
+
770
+ message += "Current Time:\n"
771
+ message += f"\tUTC: {self.utc_time}\n"
772
+ if TimeUtil.get_default_timezone() is not None:
773
+ message += f"\tServer: {self.server_time}\n"
774
+ message += f"\tUser: {self.user_time}\n"
775
+
776
+ # Record information about the user and how the webhook was invoked.
777
+ message += f"Username: {self.username}\n"
778
+ message += f"User group: {self.user_group}\n"
779
+ message += f"Webhook invocation type: {self.invocation_type}\n"
780
+ message += f"Class name: {self.class_name}\n"
781
+
782
+ # If we're able to, provide a link to the location that the error occurred at.
783
+ if self.link:
784
+ message += f"User location: {self.link}\n"
785
+
786
+ # If this state summary is for an exception, include the exception summary.
787
+ if self.exc_summary:
788
+ message += f"{self.exc_summary}\n"
789
+
790
+ return message