sapiopycommons 2024.7.24a298__py3-none-any.whl → 2024.7.29a300__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.

@@ -15,7 +15,7 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefi
15
15
  from sapiopylib.rest.pojo.webhook.ClientCallbackRequest import OptionDialogRequest, ListDialogRequest, \
16
16
  FormEntryDialogRequest, InputDialogCriteria, TableEntryDialogRequest, ESigningRequestPojo, \
17
17
  DataRecordDialogRequest, InputSelectionRequest, FilePromptRequest, MultiFilePromptRequest, \
18
- TempTableSelectionRequest
18
+ TempTableSelectionRequest, DisplayPopupRequest, PopupType
19
19
  from sapiopylib.rest.pojo.webhook.ClientCallbackResult import ESigningResponsePojo
20
20
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
21
21
  from sapiopylib.rest.pojo.webhook.WebhookEnums import FormAccessLevel, ScanToSelectCriteria, SearchType
@@ -52,11 +52,23 @@ class CallbackUtil:
52
52
  Set the width that dialogs will appear as for those dialogs that support specifying their width.
53
53
 
54
54
  :param width_pixels: The number of pixels wide that dialogs will appear as.
55
- :param width_percent: The percentage of the client's screen width that dialogs will appear as.
55
+ :param width_percent: The percentage (as a value between 0 and 1) of the client's screen width that dialogs
56
+ will appear as.
56
57
  """
57
58
  self.width_pixels = width_pixels
58
59
  self.width_percent = width_percent
59
-
60
+
61
+ def toaster_popup(self, message: str, title: str = "", popup_type: PopupType = PopupType.Info) -> None:
62
+ """
63
+ Display a toaster popup in the bottom right corner of the user's screen.
64
+
65
+ :param message: The message to display in the toaster.
66
+ :param title: The title to display at the top of the toaster.
67
+ :param popup_type: The popup type to use for the toaster. This controls the color that the toaster appears with.
68
+ Info is blue, Success is green, Warning is yellow, and Error is red
69
+ """
70
+ self.callback.display_popup(DisplayPopupRequest(title, message, popup_type))
71
+
60
72
  def option_dialog(self, title: str, msg: str, options: list[str], default_option: int = 0,
61
73
  user_can_cancel: bool = False) -> str:
62
74
  """
@@ -71,7 +83,8 @@ class CallbackUtil:
71
83
  SapioUserCancelledException is thrown.
72
84
  :return: The name of the button that the user selected.
73
85
  """
74
- request = OptionDialogRequest(title, msg, options, default_option, user_can_cancel)
86
+ request = OptionDialogRequest(title, msg, options, default_option, user_can_cancel,
87
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
75
88
  response: int | None = self.callback.show_option_dialog(request)
76
89
  if response is None:
77
90
  raise SapioUserCancelledException()
@@ -111,16 +124,20 @@ class CallbackUtil:
111
124
  """
112
125
  return self.option_dialog(title, msg, ["Yes", "No"], 0 if default_yes else 1, False) == "Yes"
113
126
 
114
- def list_dialog(self, title: str, options: list[str], multi_select: bool = False) -> list[str]:
127
+ def list_dialog(self, title: str, options: list[str], multi_select: bool = False,
128
+ preselected_values: list[str] | None = None) -> list[str]:
115
129
  """
116
130
  Create a list dialog with the given options for the user to choose from.
117
131
 
118
132
  :param title: The title of the dialog.
119
133
  :param options: The list options that the user has to choose from.
120
134
  :param multi_select: Whether the user is able to select multiple options from the list.
135
+ :param preselected_values: A list of values that will already be selected when the list dialog is created. The
136
+ user can unselect these values if they want to.
121
137
  :return: The list of options that the user selected.
122
138
  """
123
- request = ListDialogRequest(title, multi_select, options)
139
+ request = ListDialogRequest(title, multi_select, options, preselected_values,
140
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
124
141
  response: list[str] | None = self.callback.show_list_dialog(request)
125
142
  if response is None:
126
143
  raise SapioUserCancelledException()
@@ -163,8 +180,6 @@ class CallbackUtil:
163
180
  builder = FormBuilder(data_type, display_name, plural_display_name)
164
181
  for field_def in fields:
165
182
  field_name = field_def.data_field_name
166
- if values and hasattr(field_def, "default_value"):
167
- field_def.default_value = values.get(field_name)
168
183
  column: int = 0
169
184
  span: int = 4
170
185
  if column_positions and field_name in column_positions:
@@ -173,7 +188,8 @@ class CallbackUtil:
173
188
  span = position[1]
174
189
  builder.add_field(field_def, column, span)
175
190
 
176
- request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type())
191
+ request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
192
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
177
193
  response: FieldMap | None = self.callback.show_form_entry_dialog(request)
178
194
  if response is None:
179
195
  raise SapioUserCancelledException()
@@ -215,13 +231,13 @@ class CallbackUtil:
215
231
  modifier = FieldModifier(visible=True, editable=editable)
216
232
 
217
233
  # Build the form using only those fields that are desired.
234
+ values: dict[str, Any] = {}
218
235
  builder = FormBuilder(data_type, type_def.display_name, type_def.plural_display_name)
219
236
  for field_name in fields:
220
237
  field_def = field_defs.get(field_name)
221
238
  if field_def is None:
222
239
  raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
223
- if hasattr(field_def, "default_value"):
224
- field_def.default_value = record.get_field_value(field_name)
240
+ values[field_name] = record.get_field_value(field_name)
225
241
  column: int = 0
226
242
  span: int = 4
227
243
  if column_positions and field_name in column_positions:
@@ -230,7 +246,8 @@ class CallbackUtil:
230
246
  span = position[1]
231
247
  builder.add_field(modifier.modify_field(field_def), column, span)
232
248
 
233
- request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type())
249
+ request = FormEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
250
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
234
251
  response: FieldMap | None = self.callback.show_form_entry_dialog(request)
235
252
  if response is None:
236
253
  raise SapioUserCancelledException()
@@ -245,7 +262,8 @@ class CallbackUtil:
245
262
  :param field: The definition for a field that the user must provide input to.
246
263
  :return: The response value from the user for the given field.
247
264
  """
248
- request = InputDialogCriteria(title, msg, field, self.width_pixels, self.width_percent)
265
+ request = InputDialogCriteria(title, msg, field,
266
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
249
267
  response: Any | None = self.callback.show_input_dialog(request)
250
268
  if response is None:
251
269
  raise SapioUserCancelledException()
@@ -322,6 +340,8 @@ class CallbackUtil:
322
340
  msg: str,
323
341
  fields: list[AbstractVeloxFieldDefinition],
324
342
  values: list[FieldMap],
343
+ group_by: str | None = None,
344
+ image_data: list[bytes] | None = None,
325
345
  *,
326
346
  data_type: str = "Default",
327
347
  display_name: str | None = None,
@@ -335,6 +355,10 @@ class CallbackUtil:
335
355
  :param fields: The definitions of the fields to display as table columns. Fields will be displayed in the order
336
356
  they are provided in this list.
337
357
  :param values: The values to set for each row of the table.
358
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
359
+ The user may remove this grouping if they want to.
360
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
361
+ the image data list corresponds to the element at the same index in the values list.
338
362
  :param data_type: The data type name for the temporary data type that will be created for this table.
339
363
  :param display_name: The display name for the temporary data type. If not provided, defaults to the data type
340
364
  name.
@@ -356,7 +380,9 @@ class CallbackUtil:
356
380
  for field in fields:
357
381
  builder.add_field(modifier.modify_field(field))
358
382
 
359
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values)
383
+ request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
384
+ record_image_data_list=image_data, group_by_field=group_by,
385
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
360
386
  response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
361
387
  if response is None:
362
388
  raise SapioUserCancelledException()
@@ -367,7 +393,9 @@ class CallbackUtil:
367
393
  msg: str,
368
394
  fields: list[str],
369
395
  records: list[SapioRecord],
370
- editable: bool | None = True) -> list[FieldMap]:
396
+ editable: bool | None = True,
397
+ group_by: str | None = None,
398
+ image_data: list[bytes] | None = None) -> list[FieldMap]:
371
399
  """
372
400
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
373
401
  a given list of records of a singular type. Provided field names must match fields on the definition of the data
@@ -384,6 +412,10 @@ class CallbackUtil:
384
412
  they are provided in this list.
385
413
  :param editable: If true, all fields are displayed as editable. If false, all fields are displayed as
386
414
  uneditable. If none, only those fields that are defined as editable by the data designer will be editable.
415
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
416
+ The user may remove this grouping if they want to.
417
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
418
+ the image data list corresponds to the element at the same index in the records list.
387
419
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
388
420
  value from the user for that field for each row.
389
421
  """
@@ -410,7 +442,9 @@ class CallbackUtil:
410
442
  raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
411
443
  builder.add_field(modifier.modify_field(field_def))
412
444
 
413
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), field_map_list)
445
+ request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), field_map_list,
446
+ record_image_data_list=image_data, group_by_field=group_by,
447
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
414
448
  response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
415
449
  if response is None:
416
450
  raise SapioUserCancelledException()
@@ -567,7 +601,8 @@ class CallbackUtil:
567
601
  for field in final_fields:
568
602
  builder.add_field(field)
569
603
 
570
- request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values)
604
+ request = TableEntryDialogRequest(title, msg, builder.get_temporary_data_type(), values,
605
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
571
606
  response: list[FieldMap] | None = self.callback.show_table_entry_dialog(request)
572
607
  if response is None:
573
608
  raise SapioUserCancelledException()
@@ -616,7 +651,8 @@ class CallbackUtil:
616
651
  raise SapioException(f"The data type \"{data_type}\" does not have a layout by the name "
617
652
  f"\"{layout_name}\" in the system.")
618
653
 
619
- request = DataRecordDialogRequest(title, record, layout, minimized, access_level, plugin_path_list)
654
+ request = DataRecordDialogRequest(title, record, layout, minimized, access_level, plugin_path_list,
655
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
620
656
  response: bool = self.callback.data_record_form_view_dialog(request)
621
657
  if not response:
622
658
  raise SapioUserCancelledException()
@@ -663,7 +699,7 @@ class CallbackUtil:
663
699
  return response
664
700
 
665
701
  def record_selection_dialog(self, msg: str, fields: list[str], records: list[SapioRecord],
666
- multi_select: bool = True) -> list[FieldMap]:
702
+ multi_select: bool = True) -> list[SapioRecord]:
667
703
  """
668
704
  Create a record selection dialog for a list of records for the user to choose from. Provided field names must
669
705
  match fields on the definition of the data type of the given records.
@@ -676,7 +712,7 @@ class CallbackUtil:
676
712
  they are provided in this list.
677
713
  :param records: The records to display as rows in the table.
678
714
  :param multi_select: Whether the user is able to select multiple records from the list.
679
- :return: A list of field maps corresponding to the chosen input records.
715
+ :return: A list of the selected records.
680
716
  """
681
717
  data_types: set[str] = {x.data_type_name for x in records}
682
718
  if len(data_types) > 1:
@@ -807,7 +843,8 @@ class CallbackUtil:
807
843
  for field in additional_fields:
808
844
  builder.add_field(field)
809
845
  temp_dt = builder.get_temporary_data_type()
810
- request = ESigningRequestPojo(title, msg, show_comment, temp_dt)
846
+ request = ESigningRequestPojo(title, msg, show_comment, temp_dt,
847
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
811
848
  response: ESigningResponsePojo | None = self.callback.show_esign_dialog(request)
812
849
  if response is None:
813
850
  raise SapioUserCancelledException()
@@ -906,7 +943,7 @@ class CallbackUtil:
906
943
  :param file_name: The name of the file.
907
944
  :param file_data: The data of the file, provided as either a string or as a bytes array.
908
945
  """
909
- data = io.StringIO(file_data) if isinstance(file_data, str) else io.BytesIO(file_data)
946
+ data = io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)
910
947
  self.callback.send_file(file_name, False, data)
911
948
 
912
949
 
@@ -1,5 +1,6 @@
1
1
  import io
2
2
 
3
+ from sapiopylib.rest.DataMgmtService import DataMgmtServer
3
4
  from sapiopylib.rest.User import SapioUser
4
5
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
5
6
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
@@ -12,31 +13,35 @@ from sapiopycommons.recordmodel.record_handler import RecordHandler
12
13
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
13
14
  class AttachmentUtil:
14
15
  @staticmethod
15
- def get_attachment_bytes(context: SapioWebhookContext, attachment: SapioRecord) -> bytes:
16
+ def get_attachment_bytes(context: SapioWebhookContext | SapioUser, attachment: SapioRecord) -> bytes:
16
17
  """
17
18
  Get the data bytes for the given attachment record. Makes a webservice call to retrieve the data.
18
19
 
19
- :param context: The current webhook context.
20
+ :param context: The current webhook context or a user object to send requests from.
20
21
  :param attachment: The attachment record.
21
22
  :return: The bytes for the attachment's file data.
22
23
  """
23
24
  attachment = AliasUtil.to_data_record(attachment)
25
+ if isinstance(context, SapioWebhookContext):
26
+ dr_man = context.data_record_manager
27
+ else:
28
+ dr_man = DataMgmtServer.get_data_record_manager(context)
24
29
  with io.BytesIO() as data_sink:
25
30
  def consume_data(chunk: bytes):
26
31
  data_sink.write(chunk)
27
- context.data_record_manager.get_attachment_data(attachment, consume_data)
32
+ dr_man.get_attachment_data(attachment, consume_data)
28
33
  data_sink.flush()
29
34
  data_sink.seek(0)
30
35
  file_bytes = data_sink.read()
31
36
  return file_bytes
32
37
 
33
38
  @staticmethod
34
- def set_attachment_bytes(context: SapioWebhookContext, attachment: SapioRecord,
39
+ def set_attachment_bytes(context: SapioWebhookContext | SapioUser, attachment: SapioRecord,
35
40
  file_name: str, file_bytes: bytes) -> None:
36
41
  """
37
42
  Set the attachment data for a given attachment record. Makes a webservice call to set the data.
38
43
 
39
- :param context: The current webhook context.
44
+ :param context: The current webhook context or a user object to send requests from.
40
45
  :param attachment: The attachment record. Must be an existing data record that is an attachment type.
41
46
  :param file_name: The name of the attachment.
42
47
  :param file_bytes: The bytes of the attachment data.
@@ -45,8 +50,12 @@ class AttachmentUtil:
45
50
  raise SapioException("Provided record cannot have its attachment data set, as it does not exist in the "
46
51
  "system yet.")
47
52
  attachment = AliasUtil.to_data_record(attachment)
53
+ if isinstance(context, SapioWebhookContext):
54
+ dr_man = context.data_record_manager
55
+ else:
56
+ dr_man = DataMgmtServer.get_data_record_manager(context)
48
57
  with io.BytesIO(file_bytes) as stream:
49
- context.data_record_manager.set_attachment_data(attachment, file_name, stream)
58
+ dr_man.set_attachment_data(attachment, file_name, stream)
50
59
 
51
60
  @staticmethod
52
61
  def create_attachment(context: SapioWebhookContext | SapioUser, file_name: str, file_bytes: bytes,
@@ -1,7 +1,9 @@
1
1
  import time
2
2
  from collections.abc import Mapping, Iterable
3
3
 
4
+ from sapiopylib.rest.DataMgmtService import DataMgmtServer
4
5
  from sapiopylib.rest.ELNService import ElnManager
6
+ from sapiopylib.rest.User import SapioUser
5
7
  from sapiopylib.rest.pojo.DataRecord import DataRecord
6
8
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
7
9
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
@@ -17,7 +19,7 @@ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
17
19
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInstanceManager, RecordModelManager
18
20
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
19
21
 
20
- from sapiopycommons.general.aliases import AliasUtil, SapioRecord
22
+ from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier
21
23
  from sapiopycommons.general.exceptions import SapioException
22
24
 
23
25
  Step = str | ElnEntryStep
@@ -27,7 +29,8 @@ itself."""
27
29
 
28
30
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
29
31
  class ExperimentHandler:
30
- context: SapioWebhookContext
32
+ user: SapioUser
33
+ context: SapioWebhookContext | None
31
34
  """The context that this handler is working from."""
32
35
 
33
36
  # Basic experiment info from the context.
@@ -77,31 +80,57 @@ class ExperimentHandler:
77
80
  ElnExperimentStatus.Canceled]
78
81
  """The set of statuses that an ELN experiment could have and be considered locked."""
79
82
 
80
- def __init__(self, context: SapioWebhookContext, experiment: ElnExperiment | None = None):
83
+ def __init__(self, context: SapioWebhookContext | SapioUser, experiment: ExperimentIdentifier | SapioRecord | None = None):
81
84
  """
82
85
  Initialization will throw an exception if there is no ELN Experiment in the provided context and no experiment
83
86
  is provided.
84
87
 
85
- :param context: The current webhook context.
88
+ :param context: The current webhook context or a user object to send requests from.
86
89
  :param experiment: If an experiment is provided that is separate from the experiment that is in the context,
87
- that experiment will be used by this ExperimentHandler instead.
88
- """
90
+ that experiment will be used by this ExperimentHandler instead. An experiment can be provided in various
91
+ forms, including an ElnExperiment, ElnExperimentProtocol, an experiment record, or a notebook experiment ID.
92
+ """
93
+ if isinstance(context, SapioWebhookContext):
94
+ self.user = context.user
95
+ self.context = context
96
+ else:
97
+ self.user = context
98
+ self.context = None
89
99
  # FR-46495 - Allow the init function of ExperimentHandler to take in an ElnExperiment that is separate from the
90
100
  # context.
91
- if context.eln_experiment is None and experiment is None:
101
+ # CR-37038 - Allow other experiment object types to be provided. Convert them all down to ElnExperiment.
102
+ if experiment is not None:
103
+ # If this object is already an ElnExperiment, do nothing.
104
+ if isinstance(experiment, ElnExperiment):
105
+ pass
106
+ # If this object is an ElnExperimentProtocol, then we can get the ElnExperiment from the object.
107
+ elif isinstance(experiment, ElnExperimentProtocol):
108
+ experiment: ElnExperiment = experiment.eln_experiment
109
+ # If this object is an integer, assume it is a notebook ID that we can query the system with.
110
+ elif isinstance(experiment, int):
111
+ notebook_id: int = experiment
112
+ experiment: ElnExperiment = context.eln_manager.get_eln_experiment_by_id(notebook_id)
113
+ if not experiment:
114
+ raise SapioException(f"No experiment with notebook ID {notebook_id} located in the system.")
115
+ # If this object is a record, assume it is an experiment record that we can query the system with.
116
+ else:
117
+ record_id: int = AliasUtil.to_record_ids([experiment])[0]
118
+ experiment: ElnExperiment = context.eln_manager.get_eln_experiment_by_record_id(record_id)
119
+ if not experiment:
120
+ raise SapioException(f"No experiment with record ID {record_id} located in the system.")
121
+ if (context is None or context.eln_experiment is None) and experiment is None:
92
122
  raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment in the context.")
93
- if context.eln_experiment == experiment:
94
- experiment = None
95
- self.context = context
123
+ if context and context.eln_experiment == experiment:
124
+ experiment: ElnExperiment | None = None
96
125
 
97
126
  # Get the basic information about this experiment that already exists in the context and is often used.
98
127
  self.__eln_exp = experiment if experiment else context.eln_experiment
99
- self.__protocol = ElnExperimentProtocol(experiment, context.user) if experiment else context.active_protocol
128
+ self.__protocol = ElnExperimentProtocol(experiment, self.user) if experiment else context.active_protocol
100
129
  self.__exp_id = self.__protocol.get_id()
101
130
 
102
131
  # Grab various managers that may be used.
103
- self.__eln_man = context.eln_manager
104
- self.__inst_man = RecordModelManager(context.user).instance_manager
132
+ self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
133
+ self.__inst_man = RecordModelManager(self.user).instance_manager
105
134
 
106
135
  # Create empty caches to fill when necessary.
107
136
  self.__steps = {}
@@ -263,7 +292,7 @@ class ExperimentHandler:
263
292
  :return: The data record for this experiment. None if it has no record.
264
293
  """
265
294
  if not hasattr(self, "_ExperimentHandler__exp_record"):
266
- drm = self.context.data_record_manager
295
+ drm = DataMgmtServer.get_data_record_manager(self.user)
267
296
  dt = self.__eln_exp.experiment_data_type_name
268
297
  results = drm.query_data_records_by_id(dt, [self.__eln_exp.experiment_record_id]).result_list
269
298
  # PR-46504: Set the exp_record to None if there are no results.
@@ -580,8 +609,6 @@ class ExperimentHandler:
580
609
  step.set_records(AliasUtil.to_data_records(records))
581
610
 
582
611
  # FR-46496 - Provide alias of set_step_records for use with form entries.
583
- # TODO: Provide a similar aliased function for attachment entries once sapiopylib allows setting multiple
584
- # attachments to an attachment step.
585
612
  def set_form_record(self, step: Step, record: SapioRecord) -> None:
586
613
  """
587
614
  Sets the record for a form entry.
@@ -617,7 +644,7 @@ class ExperimentHandler:
617
644
  if step.eln_entry.entry_type != ElnEntryType.Table:
618
645
  raise SapioException("The provided step is not a table entry.")
619
646
  dt: str = step.get_data_type_names()[0]
620
- if not self.__is_eln_type(dt):
647
+ if not ElnBaseDataType.is_eln_type(dt):
621
648
  raise SapioException("The provided step is not an ELN data type entry.")
622
649
  return self.__inst_man.add_new_records(dt, count)
623
650
 
@@ -641,7 +668,7 @@ class ExperimentHandler:
641
668
  """
642
669
  step = self.__to_eln_step(step)
643
670
  dt: str = step.get_data_type_names()[0]
644
- if not self.__is_eln_type(dt):
671
+ if not ElnBaseDataType.is_eln_type(dt):
645
672
  raise SapioException("The provided step is not an ELN data type entry.")
646
673
  if any([x.data_type_name != dt for x in records]):
647
674
  raise SapioException("Not all of the provided records match the data type of the step.")
@@ -658,17 +685,6 @@ class ExperimentHandler:
658
685
  for record in record_models:
659
686
  record.delete()
660
687
 
661
- # TODO: Remove and use the function of the same name in ElnBaseDataType in the future. Currently this function is
662
- # bugged in sapiopylib and is comparing against base_type.name instead of base_type.value.
663
- @staticmethod
664
- def __is_eln_type(data_type: str):
665
- if data_type is None or not data_type:
666
- return False
667
- for base_type in ElnBaseDataType:
668
- if data_type.lower().startswith(base_type.value.lower()):
669
- return True
670
- return False
671
-
672
688
  def update_step(self, step: Step,
673
689
  entry_name: str | None = None,
674
690
  related_entry_set: Iterable[int] | None = None,
@@ -956,6 +972,27 @@ class ExperimentHandler:
956
972
  step.unlock_step()
957
973
  step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired
958
974
 
975
+ def disable_step(self, step: Step) -> None:
976
+ """
977
+ Set the status of the input step to Disabled. This is the state that entries are in when they are waiting for
978
+ entries that they are dependent upon to be submitted before they can be enabled. If you have unsubmitted an
979
+ entry and want its dependent entries to be locked again, then you would use this to set their status to
980
+ disabled.
981
+
982
+ Makes a webservice call to update the step. Checks if the step is already unlocked, and does nothing if so.
983
+
984
+ If no step functions have been called before and a step is being searched for by name, queries for the
985
+ list of steps in the experiment and caches them.
986
+
987
+ :param step:
988
+ The step to disable.
989
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
990
+ If given a name, throws an exception if no step of the given name exists in the experiment.
991
+ """
992
+ step = self.__to_eln_step(step)
993
+ if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
994
+ self.update_step(step, entry_status=ExperimentEntryStatus.Disabled)
995
+
959
996
  def step_is_submitted(self, step: Step) -> bool:
960
997
  """
961
998
  Determine if the input step has already been submitted.
@@ -23,7 +23,7 @@ class CDL:
23
23
  "fileName": file_name
24
24
  }
25
25
  user: SapioUser = context if isinstance(context, SapioUser) else context.user
26
- with io.StringIO(file_data) if isinstance(file_data, str) else io.BytesIO(file_data) as data_stream:
26
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data_stream:
27
27
  response = user.post_data_stream(sub_path, params=params, data_stream=data_stream)
28
28
  user.raise_for_status(response)
29
29
  # The response content is returned as bytes for a comma separated string of record IDs.
@@ -54,7 +54,7 @@ class FileBridge:
54
54
  'Filepath': f"bridge://{bridge_name}/{file_path}"
55
55
  }
56
56
  user: SapioUser = context if isinstance(context, SapioUser) else context.user
57
- with io.StringIO(file_data) if isinstance(file_data, str) else io.BytesIO(file_data) as data_stream:
57
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data_stream:
58
58
  response = user.post_data_stream(sub_path, params=params, data_stream=data_stream)
59
59
  user.raise_for_status(response)
60
60
 
@@ -21,7 +21,8 @@ class FileUtil:
21
21
  """
22
22
  @staticmethod
23
23
  def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
24
- seperator: str = ",", *, encoding: str | None = None) -> tuple[list[dict[str, str]], list[list[str]]]:
24
+ seperator: str = ",", *, encoding: str | None = None, exception_on_empty: bool = True) \
25
+ -> tuple[list[dict[str, str]], list[list[str]]]:
25
26
  """
26
27
  Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
27
28
  must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
@@ -37,6 +38,8 @@ class FileUtil:
37
38
  :param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
38
39
  contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
39
40
  ISO-8859-1 as the encoding.
41
+ :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
42
+ the first element of the returned tuple.
40
43
  :return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
41
44
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
42
45
  If the header row index is 0 or None, this list will be empty.
@@ -49,11 +52,13 @@ class FileUtil:
49
52
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
50
53
  # Parse the data from the file body into a list of dicts.
51
54
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
55
+ if exception_on_empty and not rows:
56
+ raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
52
57
  return rows, metadata
53
58
 
54
59
  @staticmethod
55
- def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0) \
56
- -> tuple[list[dict[str, str]], list[list[str]]]:
60
+ def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
61
+ *, exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
57
62
  """
58
63
  Tokenize an XLSX file row by row.
59
64
 
@@ -64,6 +69,8 @@ class FileUtil:
64
69
  row is returned in the metadata list. If input is None, then no row is considered to be the header row,
65
70
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
66
71
  is assumed to be the header row.
72
+ :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
73
+ the first element of the returned tuple.
67
74
  :return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
68
75
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
69
76
  If the header row index is 0 or None, this list will be empty.
@@ -75,6 +82,8 @@ class FileUtil:
75
82
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
76
83
  # Parse the data from the file body into a list of dicts.
77
84
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
85
+ if exception_on_empty and not rows:
86
+ raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
78
87
  return rows, metadata
79
88
 
80
89
  @staticmethod
@@ -229,7 +238,7 @@ class FileUtil:
229
238
  :param file_data: The CSV file to be converted.
230
239
  :return: The bytes of the CSV file converted to an XLSX file.
231
240
  """
232
- with (io.BytesIO(file_data) if isinstance(file_data, bytes) else io.StringIO(file_data)) as csv:
241
+ with (io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)) as csv:
233
242
  # Setting header to false makes pandas read the CSV as-is.
234
243
  data_frame = pandas.read_csv(csv, sep=",", header=None)
235
244
 
@@ -8,7 +8,6 @@ from sapiopylib.rest.pojo.CustomReport import RawReportTerm, RawTermOperation
8
8
  from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefinition, VeloxStringFieldDefinition, \
9
9
  AbstractVeloxFieldDefinition
10
10
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
- from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
12
11
 
13
12
  from sapiopycommons.callbacks.callback_util import CallbackUtil
14
13
  from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
@@ -80,7 +79,7 @@ class FileValidator:
80
79
 
81
80
  return failed_rows
82
81
 
83
- def build_violation_report(self, context: SapioWebhookResult | SapioUser,
82
+ def build_violation_report(self, context: SapioWebhookContext | SapioUser,
84
83
  rule_violations: dict[int, list[ValidationRule]]) -> None:
85
84
  """
86
85
  Build a simple report of any rule violations in the file to display to the user as a table dialog.
@@ -50,7 +50,17 @@ class AliasUtil:
50
50
 
51
51
  :return: A list of record IDs for the input records.
52
52
  """
53
- return [(x if isinstance(x, int) else x.record_id) for x in records]
53
+ return [(AliasUtil.to_record_id(x)) for x in records]
54
+
55
+ @staticmethod
56
+ def to_record_id(record: RecordIdentifier):
57
+ """
58
+ Convert a single variable that could be either an integer, DataRecord, PyRecordModel,
59
+ or WrappedRecordModel to just an integer (taking the record ID from the record).
60
+
61
+ :return: A record id for the input record.
62
+ """
63
+ return record if isinstance(record, int) else record.record_id
54
64
 
55
65
  @staticmethod
56
66
  def to_field_map_lists(records: Iterable[SapioRecord]) -> list[FieldMap]:
@@ -0,0 +1,200 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+
4
+ from sapiopylib.rest.DataRecordManagerService import DataRecordManager
5
+ from sapiopylib.rest.User import SapioUser
6
+ from sapiopylib.rest.pojo.CustomReport import ReportColumn, RawReportTerm, CustomReportCriteria, RawTermOperation, \
7
+ CompositeReportTerm, CompositeTermOperation
8
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
9
+ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
10
+
11
+ from sapiopycommons.src.sapiopycommons.general.aliases import RecordIdentifier, AliasUtil
12
+ from sapiopycommons.src.sapiopycommons.general.custom_report_util import CustomReportUtil
13
+
14
+ EVENTTYPE_COLUMN = "EVENTTYPE"
15
+ TIMESTAMP_COLUMN = "TIMESTAMP"
16
+ DATATYPENAME_COLUMN = "DATATYPENAME"
17
+ RECORDID_COLUMN = "RECORDID"
18
+ DESCRIPTION_COLUMN = "DESCRIPTION"
19
+ USERNAME_COLUMN = "USERNAME"
20
+ USERCOMMENT_COLUMN = "USERCOMMENT"
21
+ RECORDNAME_COLUMN = "RECORDNAME"
22
+ DATAFIELDNAME_COLUMN = "DATAFIELDNAME"
23
+ ORIGINALVALUE_COLUMN = "ORIGINALVALUE"
24
+ NEWVALUE_COLUMN = "NEWVALUE"
25
+
26
+
27
+ class EventType(Enum):
28
+ """An enum to represent the possible event type values with the event type column in the audit log table."""
29
+ ADD = 0
30
+ DELETE = 1
31
+ MODIFY = 2
32
+ INFO = 3
33
+ ERROR = 4
34
+ WARNING = 5
35
+ IMPORT = 6
36
+ GENERATE = 7
37
+ EXPORT = 8
38
+ ADDREF = 9
39
+ REMOVEREF = 10
40
+ ESIGNATURE = 11
41
+ ROLEASSIGNMENT = 12
42
+
43
+
44
+ class AuditLogEntry:
45
+
46
+ __event_type: EventType
47
+ __date: int
48
+ __data_type_name: str
49
+ __record_id: int
50
+ __description: str
51
+ __users_login_name: str
52
+ __comment: str
53
+ __data_record_name: str
54
+ __data_field_name: str
55
+ __original_value: str
56
+ __new_value: str
57
+
58
+ @property
59
+ def event_type(self) -> EventType:
60
+ return self.__event_type
61
+
62
+ @property
63
+ def date(self) -> int:
64
+ return self.__date
65
+
66
+ @property
67
+ def data_type_name(self) -> str:
68
+ return self.__data_type_name
69
+
70
+ @property
71
+ def record_id(self) -> int:
72
+ return self.__record_id
73
+
74
+ @property
75
+ def description(self) -> str:
76
+ return self.__description
77
+
78
+ @property
79
+ def users_login_name(self) -> str:
80
+ return self.__users_login_name
81
+
82
+ @property
83
+ def comment(self) -> str:
84
+ return self.__comment
85
+
86
+ @property
87
+ def data_record_name(self) -> str:
88
+ return self.__data_record_name
89
+
90
+ @property
91
+ def data_field_name(self) -> str:
92
+ return self.__data_field_name
93
+
94
+ @property
95
+ def original_value(self) -> str:
96
+ return self.__original_value
97
+
98
+ @property
99
+ def new_value(self) -> str:
100
+ return self.__new_value
101
+
102
+ def __init__(self, report_row: dict[str, Any]):
103
+ self.__event_type = EventType((report_row[EVENTTYPE_COLUMN]))
104
+ self.__date = report_row[TIMESTAMP_COLUMN]
105
+ self.__data_type_name = report_row[DATATYPENAME_COLUMN]
106
+ self.__record_id = report_row[RECORDID_COLUMN]
107
+ self.__description = report_row[DESCRIPTION_COLUMN]
108
+ self.__users_login_name = report_row[USERNAME_COLUMN]
109
+ self.__comment = report_row[USERCOMMENT_COLUMN]
110
+ self.__data_record_name = report_row[RECORDNAME_COLUMN]
111
+ self.__data_field_name = report_row[DATAFIELDNAME_COLUMN]
112
+ self.__original_value = report_row[ORIGINALVALUE_COLUMN]
113
+ self.__new_value = report_row[NEWVALUE_COLUMN]
114
+
115
+
116
+ class AuditLog:
117
+ AUDIT_LOG_PSEUDO_DATATYPE: str = "AUDITLOG"
118
+ EVENT_TYPE: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, EVENTTYPE_COLUMN, FieldType.ENUM)
119
+ DATE: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, TIMESTAMP_COLUMN, FieldType.DATE)
120
+ DATA_TYPE_NAME: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, DATATYPENAME_COLUMN, FieldType.STRING)
121
+ RECORD_ID: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, RECORDID_COLUMN, FieldType.LONG)
122
+ DESCRIPTION: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, DESCRIPTION_COLUMN, FieldType.STRING)
123
+ USERS_LOGIN_NAME: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, USERNAME_COLUMN, FieldType.STRING)
124
+ COMMENT: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, USERCOMMENT_COLUMN, FieldType.STRING)
125
+ DATA_RECORD_NAME: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, RECORDNAME_COLUMN, FieldType.STRING)
126
+ DATA_FIELD_NAME: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, DATAFIELDNAME_COLUMN, FieldType.STRING)
127
+ ORIGINAL_VALUE: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, ORIGINALVALUE_COLUMN, FieldType.STRING)
128
+ NEW_VALUE: ReportColumn = ReportColumn(AUDIT_LOG_PSEUDO_DATATYPE, NEWVALUE_COLUMN, FieldType.STRING)
129
+
130
+ AUDIT_LOG_COLUMNS = [EVENT_TYPE, DATE, DATA_TYPE_NAME, RECORD_ID, DESCRIPTION, USERS_LOGIN_NAME, COMMENT,
131
+ DATA_RECORD_NAME, DATA_FIELD_NAME, ORIGINAL_VALUE, NEW_VALUE]
132
+ user: SapioUser
133
+
134
+ def __init__(self, context: SapioWebhookContext | SapioUser):
135
+ self.user = context if isinstance(context, SapioUser) else context.user
136
+
137
+ @staticmethod
138
+ def create_data_record_audit_log_report(records: list[RecordIdentifier], fields: list[str] | None = None) -> CustomReportCriteria:
139
+ """
140
+ This method creates a CustomReportCriteria object for running an audit log query based on data records.
141
+
142
+ Creates a CustomReportCriteria object with a query term based on the record ids/records passed into the method.
143
+ Optionally, the fields parameter can be populated to limit the search to particular fields. If the fields
144
+ parameter is not populated, the search will include results for all field changes.
145
+
146
+ :param records: The DataRecords, RecordModels, or record ids to base the search on.
147
+ :param fields: The data field names to include changes for.
148
+ :return: The constructed CustomReportCriteria object, which can be used to run a report on the audit log.
149
+ """
150
+ # We need to compile the record ids from these record identifiers as "str" variables, so they can be
151
+ # concatenated.
152
+ record_ids = AliasUtil.to_record_ids(records)
153
+ id_strs = [str(id_int) for id_int in record_ids]
154
+
155
+ # Next we'll build the raw report term querying for any entry with a matching record id value to the record ID's
156
+ # passed in
157
+ root_term = RawReportTerm(AuditLog.AUDIT_LOG_PSEUDO_DATATYPE, RECORDID_COLUMN,
158
+ RawTermOperation.EQUAL_TO_OPERATOR, "{" + ",".join(id_strs) + "}")
159
+
160
+ # If the user passed in any specific fields, then we should limit the query to those fields.
161
+ if fields:
162
+ field_term = RawReportTerm(AuditLog.AUDIT_LOG_PSEUDO_DATATYPE, DATAFIELDNAME_COLUMN,
163
+ RawTermOperation.EQUAL_TO_OPERATOR, "{" + ",".join(fields) + "}")
164
+ root_term = CompositeReportTerm(root_term, CompositeTermOperation.AND_OPERATOR, field_term)
165
+
166
+ return CustomReportCriteria(AuditLog.AUDIT_LOG_COLUMNS, root_term)
167
+
168
+ def run_data_record_audit_log_report(self, records: list[RecordIdentifier], fields: list[str] | None = None) -> dict[RecordIdentifier, list[AuditLogEntry]]:
169
+ """
170
+ This method runs a custom report for changes made to the given data records using the audit log.
171
+ See "create_data_record_audit_log_report" for more details about the data record audit log report.
172
+
173
+ :param records: The DataRecords, RecordModels, or record ids to base the search on.
174
+ :param fields: The data field names to include changes for.
175
+ :return: A dictionary where the keys are the record identifiers passed in, and the values are a list of
176
+ AuditLogEntry objects which match the record id value of those records.
177
+ """
178
+ # First, we must build our report criteria for running the Custom Report.
179
+ criteria = AuditLog.create_data_record_audit_log_report(records, fields)
180
+
181
+ # Then we must run the custom report using that criteria.
182
+ raw_report_data: list[dict[str, Any]] = CustomReportUtil.run_custom_report(self.user, criteria)
183
+
184
+ # This section will prepare a map matching the original RecordIdentifier by record id.
185
+ # This is because the audit log entries will have record ids, but we want the keys in our result map
186
+ # to match the record identifiers that the user passed in, for convenience.
187
+ record_identifier_mapping: dict[int, RecordIdentifier] = dict()
188
+ for record in records:
189
+ record_id = AliasUtil.to_record_id(record)
190
+ record_identifier_mapping[record_id] = record
191
+
192
+ # Finally, we compile our audit data into a map where the keys are the record identifiers passed in,
193
+ # and the value is a list of applicable audit log entries.
194
+ final_audit_data: dict[RecordIdentifier, list[AuditLogEntry]] = dict()
195
+ for audit_entry_data in raw_report_data:
196
+ audit_entry: AuditLogEntry = AuditLogEntry(audit_entry_data)
197
+ identifier: RecordIdentifier = record_identifier_mapping.get(audit_entry.record_id)
198
+ final_audit_data.setdefault(identifier, []).append(audit_entry)
199
+
200
+ return final_audit_data
@@ -7,6 +7,7 @@ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTer
7
7
  from sapiopylib.rest.pojo.DataRecord import DataRecord
8
8
  from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
9
9
  from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
10
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
10
11
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
12
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
12
13
  QueryAllRecordsOfTypeAutoPager
@@ -16,6 +17,7 @@ from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelMana
16
17
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
17
18
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
18
19
  RelationshipNodeType
20
+ from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
19
21
 
20
22
  from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap
21
23
  from sapiopycommons.general.custom_report_util import CustomReportUtil
@@ -32,6 +34,7 @@ class RecordHandler:
32
34
  rec_man: RecordModelManager
33
35
  inst_man: RecordModelInstanceManager
34
36
  rel_man: RecordModelRelationshipManager
37
+ an_man: RecordModelAncestorManager
35
38
 
36
39
  def __init__(self, context: SapioWebhookContext | SapioUser):
37
40
  """
@@ -42,6 +45,7 @@ class RecordHandler:
42
45
  self.rec_man = RecordModelManager(self.user)
43
46
  self.inst_man = self.rec_man.instance_manager
44
47
  self.rel_man = self.rec_man.relationship_manager
48
+ self.an_man = RecordModelAncestorManager(self.rec_man)
45
49
 
46
50
  def wrap_model(self, record: DataRecord, wrapper_type: type[WrappedType]) -> WrappedType:
47
51
  """
@@ -51,6 +55,7 @@ class RecordHandler:
51
55
  :param wrapper_type: The record model wrapper to use.
52
56
  :return: The record model for the input.
53
57
  """
58
+ self.__verify_data_type([record], wrapper_type)
54
59
  return self.inst_man.add_existing_record_of_type(record, wrapper_type)
55
60
 
56
61
  def wrap_models(self, records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> list[WrappedType]:
@@ -61,6 +66,7 @@ class RecordHandler:
61
66
  :param wrapper_type: The record model wrapper to use.
62
67
  :return: The record models for the input.
63
68
  """
69
+ self.__verify_data_type(records, wrapper_type)
64
70
  return self.inst_man.add_existing_records_of_type(list(records), wrapper_type)
65
71
 
66
72
  def query_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
@@ -831,8 +837,6 @@ class RecordHandler:
831
837
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
832
838
  relationship path must already be loaded.
833
839
 
834
- Currently, the relationship path may only contain parent/child nodes.
835
-
836
840
  :param models: A list of record models.
837
841
  :param path: The relationship path to follow.
838
842
  :param wrapper_type: The record model wrapper to use.
@@ -843,15 +847,44 @@ class RecordHandler:
843
847
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
844
848
  path: list[RelationshipNode] = path.path
845
849
  for model in models:
846
- current: PyRecordModel = model if isinstance(model, PyRecordModel) else model.backing_model
850
+ current: PyRecordModel | None = model if isinstance(model, PyRecordModel) else model.backing_model
847
851
  for node in path:
848
- direction = node.direction
852
+ data_type: str = node.data_type_name
853
+ direction: RelationshipNodeType = node.direction
849
854
  if current is None:
850
855
  break
851
856
  if direction == RelationshipNodeType.CHILD:
852
- current = current.get_child_of_type(node.data_type_name)
857
+ current = current.get_child_of_type(data_type)
853
858
  elif direction == RelationshipNodeType.PARENT:
854
- current = current.get_parent_of_type(node.data_type_name)
859
+ current = current.get_parent_of_type(data_type)
860
+ elif direction == RelationshipNodeType.ANCESTOR:
861
+ ancestors: list[PyRecordModel] = list(self.an_man.get_ancestors_of_type(current, data_type))
862
+ if not ancestors:
863
+ current = None
864
+ elif len(ancestors) > 1:
865
+ raise SapioException(f"Hierarchy contains multiple ancestors of type {data_type}.")
866
+ else:
867
+ current = ancestors[0]
868
+ elif direction == RelationshipNodeType.DESCENDANT:
869
+ descendants: list[PyRecordModel] = list(self.an_man.get_descendant_of_type(current, data_type))
870
+ if not descendants:
871
+ current = None
872
+ elif len(descendants) > 1:
873
+ raise SapioException(f"Hierarchy contains multiple descendants of type {data_type}.")
874
+ else:
875
+ current = descendants[0]
876
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
877
+ current = current.get_forward_side_link(node.data_field_name)
878
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
879
+ field_name: str = node.data_field_name
880
+ reverse_links: list[PyRecordModel] = current.get_reverse_side_link(field_name, data_type)
881
+ if not reverse_links:
882
+ current = None
883
+ elif len(reverse_links) > 1:
884
+ raise SapioException(f"Hierarchy contains multiple reverse links of type {data_type} on field "
885
+ f"{field_name}.")
886
+ else:
887
+ current = reverse_links[0]
855
888
  else:
856
889
  raise SapioException("Unsupported path direction.")
857
890
  ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
@@ -864,8 +897,6 @@ class RecordHandler:
864
897
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
865
898
  relationship path must already be loaded.
866
899
 
867
- Currently, the relationship path may only contain parent/child nodes.
868
-
869
900
  :param models: A list of record models.
870
901
  :param path: The relationship path to follow.
871
902
  :param wrapper_type: The record model wrapper to use.
@@ -880,14 +911,23 @@ class RecordHandler:
880
911
  next_search: set[PyRecordModel] = set()
881
912
  # Exhaust the records at each step in the path, then use those records for the next step.
882
913
  for node in path:
883
- direction = node.direction
914
+ data_type: str = node.data_type_name
915
+ direction: RelationshipNodeType = node.direction
884
916
  if len(current_search) == 0:
885
917
  break
886
918
  for search in current_search:
887
919
  if direction == RelationshipNodeType.CHILD:
888
- next_search.update(search.get_children_of_type(node.data_type_name))
920
+ next_search.update(search.get_children_of_type(data_type))
889
921
  elif direction == RelationshipNodeType.PARENT:
890
- next_search.update(search.get_parents_of_type(node.data_type_name))
922
+ next_search.update(search.get_parents_of_type(data_type))
923
+ elif direction == RelationshipNodeType.ANCESTOR:
924
+ next_search.update(self.an_man.get_ancestors_of_type(search, data_type))
925
+ elif direction == RelationshipNodeType.DESCENDANT:
926
+ next_search.update(self.an_man.get_descendant_of_type(search, data_type))
927
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
928
+ next_search.add(search.get_forward_side_link(node.data_field_name))
929
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
930
+ next_search.update(search.get_reverse_side_link(node.data_field_name, data_type))
891
931
  else:
892
932
  raise SapioException("Unsupported path direction.")
893
933
  current_search = next_search
@@ -908,8 +948,6 @@ class RecordHandler:
908
948
  relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
909
949
  together into a single sample).
910
950
 
911
- Currently, the relationship path may only contain parent/child nodes.
912
-
913
951
  :param models: A list of record models.
914
952
  :param path: The relationship path to follow.
915
953
  :param wrapper_type: The record model wrapper to use.
@@ -922,13 +960,22 @@ class RecordHandler:
922
960
  for model in models:
923
961
  current: list[PyRecordModel] = [model if isinstance(model, PyRecordModel) else model.backing_model]
924
962
  for node in path:
925
- direction = node.direction
963
+ data_type: str = node.data_type_name
964
+ direction: RelationshipNodeType = node.direction
926
965
  if len(current) == 0:
927
966
  break
928
967
  if direction == RelationshipNodeType.CHILD:
929
- current = current[0].get_children_of_type(node.data_type_name)
968
+ current = current[0].get_children_of_type(data_type)
930
969
  elif direction == RelationshipNodeType.PARENT:
931
- current = current[0].get_parents_of_type(node.data_type_name)
970
+ current = current[0].get_parents_of_type(data_type)
971
+ elif direction == RelationshipNodeType.ANCESTOR:
972
+ current = list(self.an_man.get_ancestors_of_type(current[0], data_type))
973
+ elif direction == RelationshipNodeType.DESCENDANT:
974
+ current = list(self.an_man.get_descendant_of_type(current[0], data_type))
975
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
976
+ current = [current[0].get_forward_side_link(node.data_field_name)]
977
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
978
+ current = current[0].get_reverse_side_link(node.data_field_name, data_type)
932
979
  else:
933
980
  raise SapioException("Unsupported path direction.")
934
981
  ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
@@ -959,3 +1006,18 @@ class RecordHandler:
959
1006
  f"encountered in system that matches all provided identifiers.")
960
1007
  unique_record = result
961
1008
  return unique_record
1009
+
1010
+ @staticmethod
1011
+ def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1012
+ """
1013
+ Throw an exception if the data type of the given records and wrapper don't match.
1014
+ """
1015
+ model_type: str = wrapper_type.get_wrapper_data_type_name()
1016
+ for record in records:
1017
+ record_type: str = record.data_type_name
1018
+ # Account for ELN data type records.
1019
+ if ElnBaseDataType.is_eln_type(record_type):
1020
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1021
+ if record_type != model_type:
1022
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1023
+ f"of type {model_type}")
@@ -1,5 +1,5 @@
1
1
  from sapiopylib.rest.pojo.DataRecord import DataRecord
2
- from sapiopylib.rest.pojo.webhook.VeloxRules import VeloxRuleType, VeloxRuleParser
2
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
3
3
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
4
4
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager
5
5
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
@@ -12,7 +12,6 @@ from sapiopycommons.general.exceptions import SapioException
12
12
  class ElnRuleHandler:
13
13
  """
14
14
  A class which helps with the parsing and navigation of the ELN rule result map of a webhook context.
15
- TODO: Add functionality around the VeloxRuleType of the rule results.
16
15
  """
17
16
  __context: SapioWebhookContext
18
17
  """The context that this handler is working from."""
@@ -64,13 +63,8 @@ class ElnRuleHandler:
64
63
  # Get the data type of this record. If this is an ELN type, ignore the digits.
65
64
  data_type: str = record.data_type_name
66
65
  # PR-46331: Ensure that all ELN types are converted to their base data type name.
67
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
68
- if data_type.startswith("ELNExperiment_"):
69
- data_type = "ELNExperiment"
70
- elif data_type.startswith("ELNExperimentDetail_"):
71
- data_type = "ELNExperimentDetail"
72
- elif data_type.startswith("ELNSampleDetail_"):
73
- data_type = "ELNSampleDetail"
66
+ if ElnBaseDataType.is_eln_type(data_type):
67
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
74
68
  # Update the list of records of this type that exist so far globally.
75
69
  self.__records.setdefault(data_type, set()).add(record)
76
70
  # Do the same for the list of records of this type for this specific entry.
@@ -85,19 +79,9 @@ class ElnRuleHandler:
85
79
  entry_dict: dict[str, dict[int, FieldMap]] = {}
86
80
  for record_result in entry_results:
87
81
  for result in record_result.velox_type_rule_field_map_result_list:
88
- # TODO: sapiopylib currently has a bug where this velox_type_pojo variable is stored as a dict instead
89
- # of as the intended VeloxRuleType object. Parse that dict as a VeloxRuleType before use.
90
- velox_type: VeloxRuleType | dict = result.velox_type_pojo
91
- if isinstance(velox_type, dict):
92
- velox_type: VeloxRuleType = VeloxRuleParser.parse_velox_rule_type(velox_type)
93
- data_type: str = velox_type.data_type_name
94
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
95
- if data_type.startswith("ELNExperiment_"):
96
- data_type = "ELNExperiment"
97
- elif data_type.startswith("ELNExperimentDetail_"):
98
- data_type = "ELNExperimentDetail"
99
- elif data_type.startswith("ELNSampleDetail_"):
100
- data_type = "ELNSampleDetail"
82
+ data_type: str = result.velox_type_pojo.data_type_name
83
+ if ElnBaseDataType.is_eln_type(data_type):
84
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
101
85
  for field_map in result.field_map_list:
102
86
  rec_id: int = field_map.get("RecordId")
103
87
  self.__field_maps.setdefault(data_type, {}).update({rec_id: field_map})
@@ -1,5 +1,5 @@
1
1
  from sapiopylib.rest.pojo.DataRecord import DataRecord
2
- from sapiopylib.rest.pojo.webhook.VeloxRules import VeloxRuleType, VeloxRuleParser
2
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
3
3
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
4
4
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager
5
5
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
@@ -12,7 +12,6 @@ from sapiopycommons.general.exceptions import SapioException
12
12
  class OnSaveRuleHandler:
13
13
  """
14
14
  A class which helps with the parsing and navigation of the on save rule result map of a webhook context.
15
- TODO: Add functionality around the VeloxRuleType of the rule results.
16
15
  """
17
16
  __context: SapioWebhookContext
18
17
  """The context that this handler is working from."""
@@ -51,9 +50,6 @@ class OnSaveRuleHandler:
51
50
  self.__base_id_to_records = {}
52
51
  # Each record ID in the context has a list of results for that record.
53
52
  for record_id, rule_results in self.__context.velox_on_save_result_map.items():
54
- # TODO: Record IDs are currently being stored in the map as strings instead of ints. This can be removed
55
- # once sapiopylib is fixed.
56
- record_id = int(record_id)
57
53
  # Keep track of the records for this specific record ID.
58
54
  id_dict: dict[str, set[DataRecord]] = {}
59
55
  # The list of results for a record consist of a list of data records and a VeloxType that specifies
@@ -64,13 +60,8 @@ class OnSaveRuleHandler:
64
60
  # Get the data type of this record. If this is an ELN type, ignore the digits.
65
61
  data_type: str = record.data_type_name
66
62
  # PR-46331: Ensure that all ELN types are converted to their base data type name.
67
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
68
- if data_type.startswith("ELNExperiment_"):
69
- data_type = "ELNExperiment"
70
- elif data_type.startswith("ELNExperimentDetail_"):
71
- data_type = "ELNExperimentDetail"
72
- elif data_type.startswith("ELNSampleDetail_"):
73
- data_type = "ELNSampleDetail"
63
+ if ElnBaseDataType.is_eln_type(data_type):
64
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
74
65
  # Update the list of records of this type that exist so far globally.
75
66
  self.__records.setdefault(data_type, set()).add(record)
76
67
  # Do the same for the list of records of this type that relate to this record ID.
@@ -82,24 +73,11 @@ class OnSaveRuleHandler:
82
73
  self.__base_id_to_field_maps = {}
83
74
  # Repeat the same thing for the field map results.
84
75
  for record_id, rule_results in self.__context.velox_on_save_field_map_result_map.items():
85
- # TODO: Record IDs are currently being stored in the map as strings instead of ints. This can be removed
86
- # once sapiopylib is fixed.
87
- record_id = int(record_id)
88
76
  id_dict: dict[str, dict[int, FieldMap]] = {}
89
77
  for record_result in rule_results:
90
- # TODO: sapiopylib currently has a bug where this velox_type_pojo variable is stored as a dict instead
91
- # of as the intended VeloxRuleType object. Parse that dict as a VeloxRuleType before use.
92
- velox_type: VeloxRuleType | dict = record_result.velox_type_pojo
93
- if isinstance(velox_type, dict):
94
- velox_type: VeloxRuleType = VeloxRuleParser.parse_velox_rule_type(velox_type)
95
- data_type: str = velox_type.data_type_name
96
- # TODO: Use ElnBaseDataType.is_eln_type when it is no longer bugged in sapiopylib.
97
- if data_type.startswith("ELNExperiment_"):
98
- data_type = "ELNExperiment"
99
- elif data_type.startswith("ELNExperimentDetail_"):
100
- data_type = "ELNExperimentDetail"
101
- elif data_type.startswith("ELNSampleDetail_"):
102
- data_type = "ELNSampleDetail"
78
+ data_type: str = record_result.velox_type_pojo.data_type_name
79
+ if ElnBaseDataType.is_eln_type(data_type):
80
+ data_type = ElnBaseDataType.get_base_type(data_type).data_type_name
103
81
  for field_map in record_result.field_map_list:
104
82
  rec_id: int = field_map.get("RecordId")
105
83
  self.__field_maps.setdefault(data_type, {}).update({rec_id: field_map})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2024.7.24a298
3
+ Version: 2024.7.29a300
4
4
  Summary: Official Sapio Python API Utilities Package
5
5
  Project-URL: Homepage, https://github.com/sapiosciences
6
6
  Author-email: Jonathan Steck <jsteck@sapiosciences.com>, Yechen Qiao <yqiao@sapiosciences.com>
@@ -1,26 +1,27 @@
1
1
  sapiopycommons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  sapiopycommons/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- sapiopycommons/callbacks/callback_util.py,sha256=caeIWCHvK33jDs3TRskpJv0kDe7W8NPK4MyJPjgztwo,58012
3
+ sapiopycommons/callbacks/callback_util.py,sha256=YdaN1iNnJ51EauvKfEOAUwahu1dt_YVvr4Zia29g9qc,61123
4
4
  sapiopycommons/chem/IndigoMolecules.py,sha256=QqFDi9CKERj6sn_ZwVcS2xZq4imlkaTeCrpq1iNcEJA,1992
5
5
  sapiopycommons/chem/Molecules.py,sha256=t80IsQBPJ9mwE8ZxnWomAGrZDhdsOuPvLaTPb_N6jGU,8639
6
6
  sapiopycommons/chem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  sapiopycommons/datatype/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- sapiopycommons/datatype/attachment_util.py,sha256=YlnMprj5IGBbAZDLG2khS1P7JIYTw_NYfpJAfRZfP3M,3219
8
+ sapiopycommons/datatype/attachment_util.py,sha256=23JQ4avSmBBJdCv95LVj31x8rUCclzB_DYFBijH0NII,3708
9
9
  sapiopycommons/eln/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- sapiopycommons/eln/experiment_handler.py,sha256=v1pG4qtZb8OSNWfKtFo6NjnEkReqnu5R9i_hqWh_xxg,57198
10
+ sapiopycommons/eln/experiment_handler.py,sha256=RspUDmPmBQIG7l7q_luFP8yeRljdU3E64066NH5CtdI,59817
11
11
  sapiopycommons/eln/experiment_report_util.py,sha256=FTLw-6SLAMeoWTOO-qhGROE9g54pZdyoQJIhiIzlwGw,7848
12
12
  sapiopycommons/eln/plate_designer.py,sha256=FYJfhhNq8hdfuXgDYOYHy6g0m2zNwQXZWF_MTPzElDg,7184
13
13
  sapiopycommons/files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- sapiopycommons/files/complex_data_loader.py,sha256=XSJOl676mIklJo78v07-70u1b015a5DI4sqZPI3C-Tw,1475
15
- sapiopycommons/files/file_bridge.py,sha256=GI3-gWFzcL0q0c8jKOxTevbzJqtUpiElmkXfTnMsaOo,6224
14
+ sapiopycommons/files/complex_data_loader.py,sha256=8jgYF5iGDD6Abw8JRWLYxUWVwj4s4pH5HylyxEGFZSU,1471
15
+ sapiopycommons/files/file_bridge.py,sha256=njx_5Z3tvQUNW4mPazQerL8lopLAFStIByHWHJ7m5ug,6220
16
16
  sapiopycommons/files/file_bridge_handler.py,sha256=MU2wZR4VY606yx6Bnv8-LzG3mGCeuXeRBn914WNRFCo,13601
17
17
  sapiopycommons/files/file_data_handler.py,sha256=3-guAdhJdeJWAFq1a27ijspkO7uMMZ6CapMCD_6o4jA,36746
18
- sapiopycommons/files/file_util.py,sha256=44mzhn3M_QltoncBB-ooX7_yO6u5k-XU_bzUXHGxUiw,26299
19
- sapiopycommons/files/file_validator.py,sha256=BhXB2XnoNEzdBXuwul1s2RNoj-3ZoiMmephUCU_0o3Y,28113
18
+ sapiopycommons/files/file_util.py,sha256=ronTlJimQ6ttJN6Y9qfAzEWoeWnDoUbohkdObNK5mJQ,27042
19
+ sapiopycommons/files/file_validator.py,sha256=EqPCXfVCiilgnOb2G-yZg8XWeDqYp9iVCg8AaWznvf4,28040
20
20
  sapiopycommons/files/file_writer.py,sha256=5u_iZXTQvuUU7ceHZr8Q001_tvgJhOqBwAnB_pxcAbQ,16027
21
21
  sapiopycommons/general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  sapiopycommons/general/accession_service.py,sha256=HYgyOsH_UaoRnoury-c2yTW8SeG4OtjLemdpCzoV4R8,13484
23
- sapiopycommons/general/aliases.py,sha256=i6af5o2oVFGNcyk7GkvTWXQs0H9xTbFKc_GIah8NKVU,3594
23
+ sapiopycommons/general/aliases.py,sha256=vP2Fl0YEzm8z6Qe9e-SUWWAopKzK_qdvdQ9mVewg9Oc,3976
24
+ sapiopycommons/general/audit_log.py,sha256=LNpBCpZ5mz47-HzHel8PUz1TYGCnxTY6dnerZu-cwOo,9076
24
25
  sapiopycommons/general/custom_report_util.py,sha256=cLgIR5Fn3M9uyAtgfTYRv3JRk2SKNevnsb_R5zidSYs,15557
25
26
  sapiopycommons/general/exceptions.py,sha256=DOlLKnpCatxQF-lVCToa8ryJgusWLvip6N_1ALN00QE,1679
26
27
  sapiopycommons/general/popup_util.py,sha256=-mN5IgYPrLrOEHJ4CHPi2rec4_WAN6X0yMxHwD5h3Bs,30126
@@ -31,13 +32,13 @@ sapiopycommons/multimodal/multimodal_data.py,sha256=zqgYHO-ULaPKV0POFWZVY9N-Sfm1
31
32
  sapiopycommons/processtracking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
33
  sapiopycommons/processtracking/endpoints.py,sha256=g5h_uCVByqacYm9zWAz8TyAdRsGfaO2o0b5RSJdOaSA,10926
33
34
  sapiopycommons/recordmodel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- sapiopycommons/recordmodel/record_handler.py,sha256=AyK1H3x-g1eu1Mt9XD1h57yRrZp_TJjZlEaQ2kPP4Dc,54432
35
+ sapiopycommons/recordmodel/record_handler.py,sha256=qqsHeGNyqQeFKuZ4U2v2MD278AlH4n1HMvSTOY0hbyo,58636
35
36
  sapiopycommons/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- sapiopycommons/rules/eln_rule_handler.py,sha256=qfkBZtck0KK1i9s9Xe2UZqkzQOgPCzDxRkhxE8Si1xk,10671
37
- sapiopycommons/rules/on_save_rule_handler.py,sha256=JY9F30IcHwFVdgPAMQtTYuRastV1jeezhVktyrzNASU,10763
37
+ sapiopycommons/rules/eln_rule_handler.py,sha256=rz9E1PQLShCZM09JafMl_ylUqBbMs-et0FsOG41tQL0,9480
38
+ sapiopycommons/rules/on_save_rule_handler.py,sha256=I06HwsNvulyU0avSXOU0itBDAmiOxsyW8KGk_gH0FHc,9238
38
39
  sapiopycommons/webhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
40
  sapiopycommons/webhook/webhook_handlers.py,sha256=ibpBY3Sk3Eij919bIdW0awzlogYoQSWYDDOg--NwsQE,13431
40
- sapiopycommons-2024.7.24a298.dist-info/METADATA,sha256=RGnkahp9FWKycBSI9dfrZg1n-5CkU7aZM8laJZsc0fM,3176
41
- sapiopycommons-2024.7.24a298.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
42
- sapiopycommons-2024.7.24a298.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
43
- sapiopycommons-2024.7.24a298.dist-info/RECORD,,
41
+ sapiopycommons-2024.7.29a300.dist-info/METADATA,sha256=fuvVIkg01kxr3N8oqN-QG_zsljp-OfhiUn4zDgLEGH4,3176
42
+ sapiopycommons-2024.7.29a300.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
43
+ sapiopycommons-2024.7.29a300.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
44
+ sapiopycommons-2024.7.29a300.dist-info/RECORD,,