sapiopycommons 2025.4.9a150__py3-none-any.whl → 2025.4.9a476__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

Files changed (42) hide show
  1. sapiopycommons/callbacks/callback_util.py +1262 -392
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/Molecules.py +0 -2
  4. sapiopycommons/customreport/auto_pagers.py +281 -0
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/attachment_util.py +4 -2
  7. sapiopycommons/datatype/data_fields.py +23 -1
  8. sapiopycommons/eln/experiment_cache.py +173 -0
  9. sapiopycommons/eln/experiment_handler.py +933 -279
  10. sapiopycommons/eln/experiment_report_util.py +15 -10
  11. sapiopycommons/eln/experiment_step_factory.py +474 -0
  12. sapiopycommons/eln/experiment_tags.py +7 -0
  13. sapiopycommons/eln/plate_designer.py +159 -59
  14. sapiopycommons/eln/step_creation.py +235 -0
  15. sapiopycommons/files/file_bridge.py +76 -0
  16. sapiopycommons/files/file_bridge_handler.py +325 -110
  17. sapiopycommons/files/file_data_handler.py +2 -2
  18. sapiopycommons/files/file_util.py +40 -15
  19. sapiopycommons/files/file_validator.py +6 -5
  20. sapiopycommons/files/file_writer.py +1 -1
  21. sapiopycommons/flowcyto/flow_cyto.py +1 -1
  22. sapiopycommons/general/accession_service.py +3 -3
  23. sapiopycommons/general/aliases.py +51 -28
  24. sapiopycommons/general/audit_log.py +2 -2
  25. sapiopycommons/general/custom_report_util.py +24 -1
  26. sapiopycommons/general/data_structure_util.py +115 -0
  27. sapiopycommons/general/directive_util.py +86 -0
  28. sapiopycommons/general/exceptions.py +41 -2
  29. sapiopycommons/general/popup_util.py +2 -2
  30. sapiopycommons/multimodal/multimodal.py +1 -0
  31. sapiopycommons/processtracking/custom_workflow_handler.py +46 -30
  32. sapiopycommons/recordmodel/record_handler.py +547 -159
  33. sapiopycommons/rules/eln_rule_handler.py +41 -30
  34. sapiopycommons/rules/on_save_rule_handler.py +41 -30
  35. sapiopycommons/samples/aliquot.py +48 -0
  36. sapiopycommons/webhook/webhook_handlers.py +448 -55
  37. sapiopycommons/webhook/webservice_handlers.py +2 -2
  38. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/METADATA +1 -1
  39. sapiopycommons-2025.4.9a476.dist-info/RECORD +67 -0
  40. sapiopycommons-2025.4.9a150.dist-info/RECORD +0 -59
  41. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/WHEEL +0 -0
  42. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/licenses/LICENSE +0 -0
@@ -1,43 +1,64 @@
1
1
  from sapiopylib.rest.utils.Protocols import ElnEntryStep
2
+ from sapiopylib.rest.utils.plates.PlatingUtils import PlateLocation
3
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
2
4
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
3
5
 
6
+ from sapiopycommons.datatype.data_fields import PlateDesignerWellElementFields
4
7
  from sapiopycommons.eln.experiment_handler import ExperimentHandler
5
- from sapiopycommons.general.aliases import SapioRecord, RecordIdentifier, AliasUtil
8
+ from sapiopycommons.eln.experiment_tags import PLATE_IDS_TAG
9
+ from sapiopycommons.general.aliases import RecordIdentifier, AliasUtil, RecordModel, FieldValue
6
10
  from sapiopycommons.general.exceptions import SapioException
7
11
  from sapiopycommons.recordmodel.record_handler import RecordHandler
8
12
 
9
- PLATE_IDS_TAG: str = "MultiLayerPlating_Plate_RecordIdList"
13
+ # Shorthands for longer type names.
14
+ WellElement = PlateDesignerWellElementFields
10
15
 
11
16
 
12
- class PlateDesignerEntry:
17
+ # FR-47486: Change the PlateDesignerEntry class to extend ElnEntryStep instead of containing one.
18
+ class PlateDesignerEntry(ElnEntryStep):
13
19
  """
14
20
  A wrapper for 3D plate designer entries in experiments, providing functions for common actions when dealing with
15
21
  such entries.
16
22
  """
17
- step: ElnEntryStep
18
- __exp_handler: ExperimentHandler
19
- __rec_handler: RecordHandler
20
- __plates: list[SapioRecord] | None
21
- __aliquots: list[SapioRecord] | None
22
- __sources: list[SapioRecord] | None
23
- __designer_elements: list[SapioRecord] | None
24
- __plate_ids: list[int] | None
23
+ _exp_handler: ExperimentHandler
24
+ _rec_handler: RecordHandler
25
+ _plates: list[RecordModel] | None
26
+ _aliquots: list[RecordModel] | None
27
+ _sources: list[RecordModel] | None
28
+ _designer_elements: list[RecordModel] | None
29
+ _designer_elements_by_plate: dict[int, list[RecordModel]] | None
30
+ _plate_ids: list[int] | None
25
31
 
26
32
  def __init__(self, step: ElnEntryStep, exp_handler: ExperimentHandler):
27
33
  """
28
34
  :param step: The ElnEntryStep that is the 3D plate designer entry.
29
35
  :param exp_handler: An ExperimentHandler for the experiment that this entry comes from.
30
36
  """
31
- self.step = step
32
- self.__exp_handler = exp_handler
33
- self.__rec_handler = RecordHandler(exp_handler.context)
34
- self.__plates = None
35
- self.__aliquots = None
36
- self.__sources = None
37
- self.__designer_elements = None
38
- self.__plate_ids = None
37
+ super().__init__(exp_handler.protocol, step.eln_entry)
38
+ self._exp_handler = exp_handler
39
+ self._rec_handler = RecordHandler(exp_handler.user)
40
+ self._plates = None
41
+ self._aliquots = None
42
+ self._sources = None
43
+ self._designer_elements = None
44
+ self._designer_elements_by_plate = None
45
+ self._plate_ids = None
46
+
47
+ @property
48
+ def step(self) -> ElnEntryStep:
49
+ return self
50
+
51
+ def clear_cache(self) -> None:
52
+ """
53
+ Clear the caches for the plates and plate designer well elements in this plate designer entry. This will require
54
+ a new webservice call to get the plates and designer elements the next time they are requested.
55
+ """
56
+ self._plates = None
57
+ self._designer_elements = None
58
+ self._designer_elements_by_plate = None
39
59
 
40
- def get_plates(self, wrapper_type: type[WrappedType]) -> list[WrappedType]:
60
+ # CR-47491: Support not providing a wrapper type to receive PyRecordModels instead of WrappedRecordModels.
61
+ def get_plates(self, wrapper_type: type[WrappedType] | None = None) -> list[WrappedType] | list[PyRecordModel]:
41
62
  """
42
63
  Get the plates that are in the designer entry.
43
64
 
@@ -45,13 +66,16 @@ class PlateDesignerEntry:
45
66
  will be invalidated if a set_plates or add_plates call is made, requiring a new webservice call the next time
46
67
  this function is called.
47
68
 
48
- :param wrapper_type: The record model wrapper to use.
69
+ :param wrapper_type: The record model wrapper to use on the plates. If not provided, the returned records will
70
+ be PyRecordModels instead of WrappedRecordModels.
49
71
  :return: A list of the plates in the designer entry.
50
72
  """
51
- if self.__plates is not None:
52
- return self.__plates
53
- self.__plates = self.__rec_handler.query_models_by_id(wrapper_type, self.__get_plate_ids())
54
- return self.__plates
73
+ if self._plates is not None:
74
+ return self._plates
75
+ if wrapper_type is None:
76
+ wrapper_type = "Plate"
77
+ self._plates = self._rec_handler.query_models_by_id(wrapper_type, self.__get_plate_ids())
78
+ return self._plates
55
79
 
56
80
  def set_plates(self, plates: list[RecordIdentifier]) -> None:
57
81
  """
@@ -76,47 +100,50 @@ class PlateDesignerEntry:
76
100
  record_ids: list[int] = AliasUtil.to_record_ids(plates)
77
101
  self.__set_plate_ids(self.__get_plate_ids() + record_ids)
78
102
 
79
- def get_sources(self, wrapper_type: type[WrappedType]) -> list[WrappedType]:
103
+ def get_sources(self, wrapper_type: type[WrappedType] | str) -> list[WrappedType] | list[PyRecordModel]:
80
104
  """
81
105
  Get the source records that were used to populate the plate designer entry's sample table. This looks for the
82
- entries that the plate designer entry is dependent upon and gets their records if they match the data type name
83
- of the given wrapper.
106
+ entries that the plate designer entry is implicitly dependent upon and gets their records if they match the
107
+ data type name of the given wrapper.
84
108
 
85
109
  Makes a webservice call to retrieve the dependent entries if the experiment handler had not already cached it.
86
110
  Makes another webservice call to get the records from the dependent entry and caches them for future calls.
87
111
 
88
- :param wrapper_type: The record model wrapper to use.
112
+ :param wrapper_type: The record model wrapper or data type name of the records to retrieve. If a data type name
113
+ is provided, then the returned records will be PyRecordModels instead of WrappedRecordModels.
89
114
  :return: A list of the source records that populate the plate designer entry's sample table.
90
115
  """
91
- if self.__sources is not None:
92
- return self.__sources
116
+ if self._sources is not None:
117
+ return self._sources
93
118
 
94
119
  records: list[WrappedType] = []
95
- dependent_ids: list[int] = self.step.eln_entry.dependency_set
96
- for step in self.__exp_handler.get_all_steps(wrapper_type):
120
+ dependent_ids: list[int] = self.step.eln_entry.related_entry_id_set
121
+ for step in self._exp_handler.get_all_steps(wrapper_type):
97
122
  if step.get_id() in dependent_ids:
98
- records.extend(self.__exp_handler.get_step_models(step, wrapper_type))
123
+ records.extend(self._exp_handler.get_step_models(step, wrapper_type))
99
124
 
100
- self.__sources = records
101
- return self.__sources
125
+ self._sources = records
126
+ return self._sources
102
127
 
103
- def get_aliquots(self, wrapper_type: type[WrappedType]) -> list[WrappedType]:
128
+ def get_aliquots(self, wrapper_type: type[WrappedType] | None = None) -> list[WrappedType] | list[PyRecordModel]:
104
129
  """
105
130
  Get the aliquots that were created from this plate designer entry upon its submission.
106
131
 
107
132
  Makes a webservice call to retrieve the aliquots from the plate designer entry and caches them for future calls.
108
133
 
109
- :param wrapper_type: The record model wrapper to use.
134
+ :param wrapper_type: The record model wrapper to use. If not provided, the returned records will be
135
+ PyRecordModels instead of WrappedRecordModels.
110
136
  :return: A list of the aliquots created by the plate designer entry.
111
137
  """
112
- if not self.__exp_handler.step_is_submitted(self.step):
138
+ if not self._exp_handler.step_is_submitted(self.step):
113
139
  raise SapioException("The plate designer entry must be submitted before its aliquots can be retrieved.")
114
- if self.__aliquots is not None:
115
- return self.__aliquots
116
- self.__aliquots = self.__exp_handler.get_step_models(self.step, wrapper_type)
117
- return self.__aliquots
140
+ if self._aliquots is not None:
141
+ return self._aliquots
142
+ self._aliquots = self._exp_handler.get_step_models(self.step, wrapper_type)
143
+ return self._aliquots
118
144
 
119
- def get_plate_designer_well_elements(self, wrapper_type: type[WrappedType]) -> list[WrappedType]:
145
+ def get_plate_designer_well_elements(self, wrapper_type: type[WrappedType] | None = None) \
146
+ -> list[WrappedType] | list[PyRecordModel]:
120
147
  """
121
148
  Get the plate designer well elements for the plates in the plate designer entry. These are the records in the
122
149
  system that determine how wells are displayed on each plate in the entry.
@@ -125,28 +152,101 @@ class PlateDesignerEntry:
125
152
  This cache will be invalidated if a set_plates or add_plates call is made, requiring a new webservice call the
126
153
  next time this function is called.
127
154
 
128
- :param wrapper_type: The record model wrapper to use.
155
+ :param wrapper_type: The record model wrapper to use. If not provided, the returned records will be
156
+ PyRecordModels instead of WrappedRecordModels.
157
+ :return: A list of the plate designer well elements in the designer entry.
158
+ """
159
+ if self._designer_elements is not None:
160
+ return self._designer_elements
161
+ if wrapper_type is None:
162
+ wrapper_type: str = WellElement.DATA_TYPE_NAME
163
+ self._designer_elements = self._rec_handler.query_models(wrapper_type,
164
+ WellElement.PLATE_RECORD_ID__FIELD,
165
+ self.__get_plate_ids())
166
+ if self._designer_elements_by_plate is None:
167
+ self._designer_elements_by_plate = {}
168
+ self._designer_elements_by_plate.clear()
169
+ for element in self._designer_elements:
170
+ plate_id: int = element.get(WellElement.PLATE_RECORD_ID__FIELD)
171
+ self._designer_elements_by_plate.setdefault(plate_id, []).append(element)
172
+ return self._designer_elements
173
+
174
+ def get_well_elements_by_plate(self, plate: RecordIdentifier, wrapper_type: type[WrappedType] | None = None) \
175
+ -> list[WrappedType] | list[PyRecordModel]:
176
+ """
177
+ Get the plate designer well elements for the given plate in the plate designer entry. These are the records in
178
+ the system that determine how wells are displayed on the plate in the entry.
179
+
180
+ Makes a webservice call to get the plate designer well elements of the entry and caches them for future calls.
181
+ This cache will be invalidated if a set_plates or add_plates call is made, requiring a new webservice call the
182
+ next time this function is called.
183
+
184
+ :param plate: The plate to get the well elements for.
185
+ :param wrapper_type: The record model wrapper to use. If not provided, the returned records will be
186
+ PyRecordModels instead of WrappedRecordModels.
129
187
  :return: A list of the plate designer well elements in the designer entry.
130
188
  """
131
- if self.__designer_elements is not None:
132
- return self.__designer_elements
133
- self.__designer_elements = self.__rec_handler.query_models(wrapper_type, "PlateRecordId",
134
- self.__get_plate_ids())
135
- return self.__designer_elements
189
+ plate: int = AliasUtil.to_record_id(plate)
190
+ if plate not in self.__get_plate_ids():
191
+ raise SapioException(f"Plate record ID {plate} is not in this plate designer entry.")
192
+ if self._designer_elements_by_plate is None:
193
+ self.get_plate_designer_well_elements(wrapper_type)
194
+ return self._designer_elements_by_plate[plate]
195
+
196
+ def create_well_element(self, sample: RecordModel, plate: RecordModel, location: PlateLocation | None = None,
197
+ wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
198
+ """
199
+ Create a new plate designer well element for the input sample and plate. A record model manager store and commit
200
+ must be called to save this new well element to the server.
201
+
202
+ :param sample: The sample that the element is for. Must exist in the system (i.e. have a >0 record ID).
203
+ :param plate: The plate that the element is for. Must exist in the system (i.e. have a >0 record ID).
204
+ :param location: The location of the well element. If not provided, the row and column position fields of the
205
+ sample will be used.
206
+ :param wrapper_type: The record model wrapper to use for the plate designer well element. If not provided, the
207
+ returned record will be a PyRecordModel instead of a WrappedRecordModel.
208
+ :return: The newly created PlateDesignerWellElementModel.
209
+ """
210
+ # Confirm that we can actually make a designer element for the input records.
211
+ if AliasUtil.to_record_id(sample) <= 0:
212
+ raise SapioException("Cannot create plate designer well element for sample without a record ID.")
213
+ if AliasUtil.to_record_id(plate) <= 0:
214
+ raise SapioException("Cannot create plate designer well element for plate without a record ID.")
215
+ if AliasUtil.to_data_type_name(sample) != "Sample":
216
+ raise SapioException("Sample record must be of type Sample.")
217
+ if AliasUtil.to_data_type_name(plate) != "Plate":
218
+ raise SapioException("Plate record must be of type Plate.")
219
+
220
+ dt: type[WrappedType] | str = wrapper_type if wrapper_type else WellElement.DATA_TYPE_NAME
221
+ plate_id: int = AliasUtil.to_record_id(plate)
222
+ fields: dict[str, FieldValue] = {
223
+ WellElement.SOURCE_RECORD_ID__FIELD: AliasUtil.to_record_id(sample),
224
+ WellElement.PLATE_RECORD_ID__FIELD: plate_id,
225
+ WellElement.ROW_POSITION__FIELD: location.row_pos if location else sample.get_field_value("RowPosition"),
226
+ WellElement.COL_POSITION__FIELD: str(location.col_pos) if location else sample.get_field_value("ColPosition"),
227
+ WellElement.SOURCE_DATA_TYPE_NAME__FIELD: "Sample",
228
+ }
229
+ element = self._rec_handler.add_models_with_data(dt, [fields])[0]
230
+
231
+ # Add the new element to the cache.
232
+ if self._designer_elements is not None:
233
+ self._designer_elements.append(element)
234
+ if self._designer_elements_by_plate is not None and plate_id in self._designer_elements_by_plate:
235
+ self._designer_elements_by_plate.setdefault(plate_id, []).append(element)
236
+ return element
136
237
 
137
238
  def __get_plate_ids(self) -> list[int]:
138
- if self.__plate_ids is not None:
139
- return self.__plate_ids
140
- id_tag: str = self.__exp_handler.get_step_option(self.step, PLATE_IDS_TAG)
239
+ if self._plate_ids is not None:
240
+ return self._plate_ids
241
+ id_tag: str = self._exp_handler.get_step_option(self.step, PLATE_IDS_TAG)
141
242
  if not id_tag:
142
243
  raise SapioException("No plates in the plate designer entry")
143
- self.__plate_ids = [int(x) for x in id_tag.split(",")]
144
- return self.__plate_ids
244
+ self._plate_ids = [int(x) for x in id_tag.split(",")]
245
+ return self._plate_ids
145
246
 
146
247
  def __set_plate_ids(self, record_ids: list[int]) -> None:
147
248
  record_ids.sort()
148
- self.__exp_handler.add_step_options(self.step, {PLATE_IDS_TAG: ",".join([str(x) for x in record_ids])})
149
- self.__plate_ids = record_ids
249
+ self._exp_handler.add_step_options(self.step, {PLATE_IDS_TAG: ",".join([str(x) for x in record_ids])})
250
+ self._plate_ids = record_ids
150
251
  # The plates and designer elements caches have been invalidated.
151
- self.__plates = None
152
- self.__designer_elements = None
252
+ self.clear_cache()
@@ -0,0 +1,235 @@
1
+ from typing import Iterable
2
+
3
+ from sapiopycommons.general.aliases import DataTypeIdentifier, FieldIdentifier, ExperimentEntryIdentifier
4
+ from sapiopylib.rest.pojo.TableColumn import TableColumn
5
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition
6
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnEntryType
7
+ from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
8
+
9
+
10
+ # CR-47564: Created these classes to streamline entry creation using the ExperimentEntryFactory.
11
+ class StepCreation:
12
+ """
13
+ An object that contains the criteria for creating a new entry in the experiment.
14
+ """
15
+ _entry_type: ElnEntryType
16
+ """The type of the entry to be created."""
17
+ is_shown_in_template: bool | None
18
+ """Whether the entry will appear in the template if the experiment this entry is in is saved to a new template."""
19
+ is_removable: bool | None
20
+ """Whether the entry can be removed by users."""
21
+ is_renamable: bool | None
22
+ """Whether the entry can be renamed by users."""
23
+ is_static_view: bool | None
24
+ """Whether the entry's attachment is static. For attachment entries only. Static attachment entries will store
25
+ their attachment data in the template."""
26
+ related_entry_set: Iterable[ExperimentEntryIdentifier | str] | None
27
+ """The IDs of the entries this entry is implicitly dependent on. If any of the entries are deleted then this entry
28
+ is also deleted."""
29
+ dependency_set: Iterable[ExperimentEntryIdentifier | str] | None
30
+ """The IDs of the entries this entry is dependent on. Requires the entries to be completed before this entry will
31
+ be enabled."""
32
+ requires_grabber_plugin: bool
33
+ """Whether to run a grabber plugin when this entry is initialized."""
34
+ entry_singleton_id: str | None
35
+ """When this field is present (i.e. not null or blank) it will enforce that only one entry with this singleton
36
+ value is present in the experiment. If you attempt to create an entry with the singletonId of an entry already
37
+ present in the experiment it will return the existing entry instead of creating a new one. If an entry isn't
38
+ present in the Notebook Experiment with a matching singletonId it will create a new entry like normal."""
39
+ is_hidden: bool | None
40
+ """Whether the user is able to visibly see this entry within the experiment."""
41
+ entry_height: int | None
42
+ """The height of this entry in pixels. Setting the height to 0 will cause the entry to auto-size to its contents."""
43
+ description: str | None
44
+ """The description of the entry."""
45
+ is_initialization_required: bool | None
46
+ """Whether the user must manually initialize this entry by clicking on it."""
47
+ collapse_entry: bool | None
48
+ """Whether the entry should be collapsed by default."""
49
+ entry_status: ExperimentEntryStatus | None
50
+ """The current status of the entry."""
51
+ template_item_fulfilled_timestamp: int | None
52
+ """The time in milliseconds since the epoch that this entry became initialized."""
53
+ entry_options: dict[str, str] | None
54
+ """The entry options of the entry."""
55
+
56
+ def __init__(self, entry_type: ElnEntryType):
57
+ self._entry_type = entry_type
58
+ self.is_shown_in_template = None
59
+ self.is_removable = None
60
+ self.is_renamable = None
61
+ self.is_static_view = None
62
+ self._related_entry_set = None
63
+ self._dependency_set = None
64
+ self.requires_grabber_plugin = False
65
+ self.entry_singleton_id = None
66
+ self.is_hidden = None
67
+ self.entry_height = None
68
+ self.description = None
69
+ self.is_initialization_required = None
70
+ self.collapse_entry = None
71
+ self.entry_status = None
72
+ self.template_item_fulfilled_timestamp = None
73
+ self.entry_options = None
74
+
75
+ @property
76
+ def entry_type(self) -> ElnEntryType:
77
+ return self._entry_type
78
+
79
+ class AttachmentStepCreation(StepCreation):
80
+ """
81
+ An object that contains criteria for creating a new attachment entry in an experiment.
82
+ """
83
+ def __init__(self):
84
+ super().__init__(ElnEntryType.Attachment)
85
+
86
+
87
+ class DashboardStepCreation(StepCreation):
88
+ """
89
+ An object that contains criteria for creating a new dashboard entry in an experiment.
90
+ """
91
+ source_entry: ExperimentEntryIdentifier | str | None
92
+ """The entry that contains the source data for this entry's dashboard(s)."""
93
+ dashboard_guids: Iterable[str] | None
94
+ """The GUIDs of the dashboards to display in this entry."""
95
+
96
+ def __init__(self):
97
+ super().__init__(ElnEntryType.Dashboard)
98
+ self.source_entry = None
99
+ self.dashboard_guids = None
100
+
101
+
102
+ class GlobalDtFormStepCreation(StepCreation):
103
+ """
104
+ An object that contains criteria for creating a new global data type form entry in an experiment.
105
+ """
106
+ layout_name: str | None
107
+ """The name of the data type layout to be displayed in this form. The layout must be for the data type for this
108
+ entry."""
109
+ form_names: Iterable[str] | None
110
+ """The names of the components in the chosen data type layout to display in this form."""
111
+ extension_types: Iterable[DataTypeIdentifier] | None
112
+ """The names of the extension data types to display fields from within the form."""
113
+ field_names: Iterable[FieldIdentifier] | None
114
+ """A list of data field names for the fields to be displayed in the form."""
115
+
116
+ def __init__(self):
117
+ super().__init__(ElnEntryType.Form)
118
+ self.form_names = None
119
+ self.layout_name = None
120
+ self.extension_types = None
121
+ self.field_names = None
122
+
123
+
124
+ class ELnDtFormStepCreation(StepCreation):
125
+ """
126
+ An object that contains criteria for creating a new ELN data type form entry in an experiment.
127
+ """
128
+ is_field_addable: bool | None
129
+ """Whether new fields can be added to the entry by users."""
130
+ is_existing_field_removable: bool | None
131
+ """Whether existing fields on the entry can be removed by users."""
132
+ field_sets: Iterable[int | str | ElnFieldSetInfo] | None
133
+ """The predefined field sets to display in this form."""
134
+ field_definitions: Iterable[AbstractVeloxFieldDefinition] | None
135
+ """New field definitions to be created for this entry."""
136
+ predefined_field_names: Iterable[str] | None
137
+ """The names of the predefined fields to display in this form."""
138
+
139
+ def __init__(self):
140
+ super().__init__(ElnEntryType.Form)
141
+ self.is_field_addable = None
142
+ self.is_existing_field_removable = None
143
+ self.field_sets = None
144
+ self.field_definitions = None
145
+ self.predefined_field_names = None
146
+ self.table_columns = None
147
+
148
+
149
+ class PluginStepCreation(StepCreation):
150
+ """
151
+ An object that contains criteria for creating a new plugin entry in an experiment.
152
+ """
153
+ plugin_name: str | None
154
+ """The client side plugin name to render this entry with."""
155
+ using_template_data: bool | None
156
+ """Whether this entry will use the data from the template."""
157
+ provides_template_data: bool | None
158
+ """Whether this entry can provide data to copy into a new template."""
159
+
160
+ def __init__(self):
161
+ super().__init__(ElnEntryType.Plugin)
162
+ self.plugin_name = None
163
+ self.using_template_data = None
164
+ self.provides_template_data = None
165
+
166
+
167
+ class GlobalDtTableStepCreation(StepCreation):
168
+ """
169
+ An object that contains criteria for creating a new global data type table entry in an experiment.
170
+ """
171
+ layout_name: str | None
172
+ """The name of the data type layout to display in this table."""
173
+ extension_types: Iterable[str] | None
174
+ """The names of the extension data types to display fields from within the table."""
175
+ table_columns: Iterable[TableColumn] | None
176
+ """The columns to display in the table. This can be used to change the sort order and direction of columns."""
177
+ field_names: Iterable[FieldIdentifier] | None
178
+ """A list of data field names for the fields to be displayed in the table. These will be added as TableColumns and
179
+ placed after any of the existing columns specified in the table_columns parameter without any sorting."""
180
+ show_key_fields: bool | None
181
+ """Whether the key fields of the data type should be shown in the entry."""
182
+
183
+ def __init__(self):
184
+ super().__init__(ElnEntryType.Table)
185
+ self.layout_name = None
186
+ self.extension_types = None
187
+ self.table_columns = None
188
+ self.show_key_fields = None
189
+
190
+
191
+ class ELnDtTableStepCreation(StepCreation):
192
+ """
193
+ An object that contains criteria for creating a new ELN data type table entry in an experiment.
194
+ """
195
+ is_field_addable: bool | None
196
+ """Whether new fields can be added to the entry by users."""
197
+ is_existing_field_removable: bool | None
198
+ """Whether existing fields on the entry can be removed by users."""
199
+ field_sets: Iterable[int | str | ElnFieldSetInfo] | None
200
+ """The predefined field sets to display in this form."""
201
+ field_definitions: Iterable[AbstractVeloxFieldDefinition] | None
202
+ """New field definitions to be created for this entry."""
203
+ predefined_field_names: Iterable[str] | None
204
+ """The names of the predefined fields to display in this form."""
205
+ table_columns: Iterable[TableColumn] | None
206
+ """The columns to display in the table."""
207
+
208
+ def __init__(self):
209
+ super().__init__(ElnEntryType.Table)
210
+ self.is_field_addable = None
211
+ self.is_existing_field_removable = None
212
+ self.field_sets = None
213
+ self.field_definitions = None
214
+ self.predefined_field_names = None
215
+ self.table_columns = None
216
+
217
+
218
+ class TempDataStepCreation(StepCreation):
219
+ """
220
+ An object that contains criteria for creating a new temp data entry in an experiment.
221
+ """
222
+ plugin_path: str | None
223
+ """The temp data plugin path to run to populate the entry."""
224
+
225
+ def __init__(self):
226
+ super().__init__(ElnEntryType.TempData)
227
+ self.plugin_path = None
228
+
229
+
230
+ class TextStepCreation(StepCreation):
231
+ """
232
+ An object that contains criteria for creating a new text entry in an experiment.
233
+ """
234
+ def __init__(self):
235
+ super().__init__(ElnEntryType.Text)
@@ -1,6 +1,7 @@
1
1
  import base64
2
2
  import io
3
3
  import urllib.parse
4
+ from typing import Any
4
5
 
5
6
  from requests import Response
6
7
  from sapiopylib.rest.User import SapioUser
@@ -8,6 +9,36 @@ from sapiopylib.rest.User import SapioUser
8
9
  from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
9
10
 
10
11
 
12
+ # FR-47387: Add support for the metadata endpoints in FileBridge.
13
+ class FileBridgeMetadata:
14
+ """
15
+ Metadata for a file or directory in FileBridge.
16
+ """
17
+ file_name: str
18
+ """The name of the file or directory."""
19
+ is_file: bool
20
+ """True if the metadata is for a file, False if it is for a directory."""
21
+ is_directory: bool
22
+ """True if the metadata is for a directory, False if it is for a file."""
23
+ size: int
24
+ """The size of the file in bytes. For directories, this value will always be zero."""
25
+ creation_time: int
26
+ """The time the file or directory was created, in milliseconds since the epoch."""
27
+ last_accessed_time: int
28
+ """The time the file or directory was last accessed, in milliseconds since the epoch."""
29
+ last_modified_time: int
30
+ """The time the file or directory was last modified, in milliseconds since the epoch."""
31
+
32
+ def __init__(self, json_dict: dict[str, Any]):
33
+ self.file_name = json_dict['fileName']
34
+ self.is_file = json_dict['isFile']
35
+ self.is_directory = json_dict['isDirectory']
36
+ self.size = json_dict['size']
37
+ self.creation_time = json_dict['creationTime']
38
+ self.last_accessed_time = json_dict['lastAccessTime']
39
+ self.last_modified_time = json_dict['lastModifiedTime']
40
+
41
+
11
42
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
12
43
  class FileBridge:
13
44
  @staticmethod
@@ -137,3 +168,48 @@ class FileBridge:
137
168
  user: SapioUser = AliasUtil.to_sapio_user(context)
138
169
  response = user.delete(sub_path, params=params)
139
170
  user.raise_for_status(response)
171
+
172
+ @staticmethod
173
+ def file_metadata(context: UserIdentifier, bridge_name: str, file_path: str) -> FileBridgeMetadata:
174
+ """
175
+ Get metadata for a file or directory in FileBridge.
176
+
177
+ The file path may be to a directory, in which case only the metadata for that directory will be returned. If you
178
+ want the metadata for the contents of a directory, then use the directory_metadata function.
179
+
180
+ :param context: The current webhook context or a user object to send requests from.
181
+ :param bridge_name: The name of the bridge to use. This is the "connection name" in the
182
+ file bridge configurations.
183
+ :param file_path: The path to the file to retrieve the metadata from.
184
+ :return: The metadata for the file.
185
+ """
186
+ sub_path = '/ext/filebridge/file/metadata'
187
+ params = {
188
+ 'Filepath': f"bridge://{bridge_name}/{file_path}"
189
+ }
190
+ user: SapioUser = AliasUtil.to_sapio_user(context)
191
+ response = user.get(sub_path, params=params)
192
+ user.raise_for_status(response)
193
+ response_body: dict[str, Any] = response.json()
194
+ return FileBridgeMetadata(response_body)
195
+
196
+ @staticmethod
197
+ def directory_metadata(context: UserIdentifier, bridge_name: str, file_path: str) -> list[FileBridgeMetadata]:
198
+ """
199
+ Get metadata for every file or nested directory in a directory in FileBridge.
200
+
201
+ :param context: The current webhook context or a user object to send requests from.
202
+ :param bridge_name: The name of the bridge to use. This is the "connection name" in the
203
+ file bridge configurations.
204
+ :param file_path: The path to the directory to retrieve the metadata of the contents.
205
+ :return: A list of the metadata for the contents of the directory.
206
+ """
207
+ sub_path = '/ext/filebridge/directory/metadata'
208
+ params = {
209
+ 'Filepath': f"bridge://{bridge_name}/{file_path}"
210
+ }
211
+ user: SapioUser = AliasUtil.to_sapio_user(context)
212
+ response = user.get(sub_path, params=params)
213
+ user.raise_for_status(response)
214
+ response_body: list[dict[str, Any]] = response.json()
215
+ return [FileBridgeMetadata(x) for x in response_body]