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,19 +1,35 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
3
+ import warnings
4
4
  from collections.abc import Mapping, Iterable
5
+ from typing import TypeAlias
5
6
  from weakref import WeakValueDictionary
6
7
 
8
+ from sapiopycommons.eln.experiment_cache import ExperimentCacheManager
9
+ from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
10
+ from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
11
+ DataTypeIdentifier, RecordModel, ExperimentEntryIdentifier, TabIdentifier
12
+ from sapiopycommons.general.exceptions import SapioException
13
+ from sapiopycommons.general.time_util import TimeUtil
14
+ from sapiopycommons.recordmodel.record_handler import RecordHandler
7
15
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
8
16
  from sapiopylib.rest.ELNService import ElnManager
9
17
  from sapiopylib.rest.User import SapioUser
10
18
  from sapiopylib.rest.pojo.DataRecord import DataRecord
19
+ from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
11
20
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
12
21
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
13
- from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
14
- from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria
22
+ from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry, ExperimentTableEntry, ExperimentFormEntry, \
23
+ ExperimentAttachmentEntry, ExperimentPluginEntry, ExperimentDashboardEntry, ExperimentTextEntry, \
24
+ ExperimentTempDataEntry
25
+ from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria, \
26
+ ElnTableEntryUpdateCriteria, ElnFormEntryUpdateCriteria, ElnAttachmentEntryUpdateCriteria, \
27
+ ElnPluginEntryUpdateCriteria, ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria, \
28
+ ElnTempDataEntryUpdateCriteria
15
29
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnExperimentStatus, ElnEntryType, \
16
30
  ElnBaseDataType
31
+ from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab, ElnExperimentTabAddCriteria
32
+ from sapiopylib.rest.pojo.eln.protocol_template import ProtocolTemplateInfo
17
33
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
18
34
  from sapiopylib.rest.pojo.webhook.WebhookDirective import ElnExperimentDirective
19
35
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
@@ -23,12 +39,7 @@ from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInst
23
39
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
24
40
  from sapiopylib.rest.utils.recordmodel.properties import Child
25
41
 
26
- from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
27
- from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
28
- DataTypeIdentifier, RecordModel
29
- from sapiopycommons.general.exceptions import SapioException
30
-
31
- Step = str | ElnEntryStep
42
+ Step: TypeAlias = str | ExperimentEntryIdentifier
32
43
  """An object representing an identifier to an ElnEntryStep. May be either the name of the step or the ElnEntryStep
33
44
  itself."""
34
45
 
@@ -37,53 +48,74 @@ itself."""
37
48
  class ExperimentHandler:
38
49
  user: SapioUser
39
50
  context: SapioWebhookContext | None
40
- """The context that this handler is working from."""
51
+ """The context that this handler is working from, if any."""
41
52
 
53
+ # CR-47485: Made variables protected instead of private.
42
54
  # Basic experiment info from the context.
43
- __eln_exp: ElnExperiment
55
+ _eln_exp: ElnExperiment
44
56
  """The ELN experiment from the context."""
45
- __protocol: ElnExperimentProtocol
57
+ _protocol: ElnExperimentProtocol
46
58
  """The ELN experiment as a protocol."""
47
- __exp_id: int
59
+ _exp_id: int
48
60
  """The ID of this experiment's notebook. Used for making update webservice calls."""
49
61
 
50
62
  # Managers.
51
- __eln_man: ElnManager
63
+ _eln_man: ElnManager
52
64
  """The ELN manager. Used for updating the experiment and its steps."""
53
- __inst_man: RecordModelInstanceManager
65
+ _exp_cache: ExperimentCacheManager
66
+ """The experiment cache manager. Used for caching experiment-related information."""
67
+ _inst_man: RecordModelInstanceManager
54
68
  """The record model instance manager. Used for wrapping the data records of a step as record models."""
69
+ _rec_handler: RecordHandler
70
+ """The record handler. Also used for wrapping the data records of a step as record models."""
55
71
 
56
72
  # Only a fraction of the information about the current experiment exists in the context. Much information requires
57
73
  # additional queries to obtain, but may also be repeatedly accessed. In such cases, cache the information after it
58
74
  # has been requested so that the user doesn't need to worry about caching it themselves.
59
75
  # CR-46341: Replace class variables with instance variables.
60
- __exp_record: DataRecord | None
76
+ _exp_record: DataRecord | None
61
77
  """The data record for this experiment. Only cached when first accessed."""
62
- __exp_template: ElnTemplate | None
78
+ _exp_template: ElnTemplate | None
63
79
  """The template for this experiment. Only cached when first accessed."""
64
- __exp_options: dict[str, str]
80
+ _exp_options: dict[str, str]
65
81
  """Experiment options for this experiment. Only cached when first accessed."""
66
82
 
67
- __queried_all_steps: bool
83
+ _queried_all_steps: bool
68
84
  """Whether this ExperimentHandler has queried the system for all steps in the experiment."""
69
- __steps: dict[str, ElnEntryStep]
70
- """Steps from this experiment. All steps are cached the first time any individual step is accessed."""
71
- __step_options: dict[int, dict[str, str]]
85
+ _steps: list[ElnEntryStep]
86
+ """The sorted list of steps for this experiment. All steps are cached the first time any individual step is accessed."""
87
+ _steps_by_name: dict[str, ElnEntryStep]
88
+ """Steps from this experiment by their name. All steps are cached the first time any individual step is accessed."""
89
+ _steps_by_id: dict[int, ElnEntryStep]
90
+ """Steps from this experiment by their ID. All steps are cached the first time any individual step is accessed."""
91
+ _step_options: dict[int, dict[str, str]]
72
92
  """Entry options for each step in this experiment. All entry options are cached the first time any individual step's
73
93
  options are queried. The cache is updated whenever the entry options for a step are changed by this handler."""
74
94
 
95
+ _step_updates: dict[int, AbstractElnEntryUpdateCriteria]
96
+ """A dictionary of entry updates that have been made by this handler. Used to batch update entries."""
97
+
98
+ _queried_all_tabs: bool
99
+ """Whether this ExperimentHandler has queried the system for all tabs in the experiment."""
100
+ _tabs: list[ElnExperimentTab]
101
+ """The sorted tabs for this experiment. Only cached when first accessed."""
102
+ _tabs_by_id: dict[int, ElnExperimentTab]
103
+ """The tabs for this experiment by their ID. Only cached when first accessed."""
104
+ _tabs_by_name: dict[str, ElnExperimentTab]
105
+ """The tabs for this experiment by their name. Only cached when first accessed."""
106
+
75
107
  # Constants
76
- __ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
108
+ _ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
77
109
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
78
- __ENTRY_LOCKED_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved,
79
- ExperimentEntryStatus.Disabled, ExperimentEntryStatus.LockedAwaitingApproval,
80
- ExperimentEntryStatus.LockedRejected]
110
+ _ENTRY_LOCKED_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved,
111
+ ExperimentEntryStatus.Disabled, ExperimentEntryStatus.LockedAwaitingApproval,
112
+ ExperimentEntryStatus.LockedRejected]
81
113
  """The set of statuses that an ELN entry could have and be considered locked."""
82
- __EXPERIMENT_COMPLETE_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved]
114
+ _EXPERIMENT_COMPLETE_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved]
83
115
  """The set of statuses that an ELN experiment could have and be considered completed."""
84
- __EXPERIMENT_LOCKED_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved,
85
- ElnExperimentStatus.LockedRejected, ElnExperimentStatus.LockedAwaitingApproval,
86
- ElnExperimentStatus.Canceled]
116
+ _EXPERIMENT_LOCKED_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved,
117
+ ElnExperimentStatus.LockedRejected, ElnExperimentStatus.LockedAwaitingApproval,
118
+ ElnExperimentStatus.Canceled]
87
119
  """The set of statuses that an ELN experiment could have and be considered locked."""
88
120
 
89
121
  __instances: WeakValueDictionary[str, ExperimentHandler] = WeakValueDictionary()
@@ -123,27 +155,44 @@ class ExperimentHandler:
123
155
  experiment = param_results[2]
124
156
 
125
157
  # Get the basic information about this experiment that already exists in the context and is often used.
126
- self.__eln_exp = experiment
127
- self.__protocol = ElnExperimentProtocol(experiment, self.user)
128
- self.__exp_id = self.__protocol.get_id()
158
+ self._eln_exp = experiment
159
+ self._protocol = ElnExperimentProtocol(experiment, self.user)
160
+ self._exp_id = self._protocol.get_id()
129
161
 
130
162
  # Grab various managers that may be used.
131
- self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
132
- self.__inst_man = RecordModelManager(self.user).instance_manager
163
+ self._eln_man = DataMgmtServer.get_eln_manager(self.user)
164
+ self._exp_cache = ExperimentCacheManager(self.user)
165
+ self._inst_man = RecordModelManager(self.user).instance_manager
166
+ self._rec_handler = RecordHandler(self.user)
133
167
 
134
168
  # Create empty caches to fill when necessary.
135
- self.__steps = {}
136
- self.__step_options = {}
169
+ self._queried_all_steps = False
170
+ self._steps_by_name = {}
171
+ self._steps_by_id = {}
172
+ self._steps = []
173
+ self._step_options = {}
174
+ self._step_updates = {}
175
+
176
+ self._tabs = []
177
+ self._tabs_by_id = {}
178
+ self._tabs_by_name = {}
179
+
180
+ self._queried_all_tabs = False
181
+
137
182
  # CR-46330: Cache any experiment entry information that might already exist in the context.
138
- self.__queried_all_steps = False
139
183
  # We can only trust the entries in the context if the experiment that this handler is for is the same as the
140
184
  # one from the context.
141
185
  if self.context is not None and self.context.eln_experiment == experiment:
186
+ cache_steps: list[ElnEntryStep] = []
142
187
  if self.context.experiment_entry is not None:
143
- self.__steps.update({self.context.active_step.get_name(): self.context.active_step})
188
+ cache_steps.append(ElnEntryStep(self._protocol, self.context.experiment_entry))
144
189
  if self.context.experiment_entry_list is not None:
145
190
  for entry in self.context.experiment_entry_list:
146
- self.__steps.update({entry.entry_name: ElnEntryStep(self.__protocol, entry)})
191
+ cache_steps.append(ElnEntryStep(self._protocol, entry))
192
+ for step in cache_steps:
193
+ self._steps.append(step)
194
+ self._steps_by_name.update({step.get_name(): step})
195
+ self._steps_by_id.update({step.get_id(): step})
147
196
 
148
197
  @staticmethod
149
198
  def __parse_params(context: UserIdentifier, experiment: ExperimentIdentifier | SapioRecord | None = None) \
@@ -180,10 +229,99 @@ class ExperimentHandler:
180
229
  if not experiment:
181
230
  raise SapioException(f"No experiment with record ID {record_id} located in the system.")
182
231
  if experiment is None:
183
- raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided parameters.")
232
+ raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided "
233
+ "parameters.")
184
234
 
185
235
  return user, context, experiment
186
236
 
237
+ @property
238
+ def protocol(self) -> ElnExperimentProtocol:
239
+ """
240
+ The ELN experiment that this handler is for as a protocol object.
241
+ """
242
+ return self._protocol
243
+
244
+ # CR-47485: Add methods for clearing and updating the caches of this ExperimentHandler.
245
+ def clear_all_caches(self) -> None:
246
+ """
247
+ Clear all caches that this ExperimentHandler uses.
248
+ """
249
+ self.clear_step_caches()
250
+ self.clear_experiment_caches()
251
+ self.clear_tab_caches()
252
+
253
+ def clear_step_caches(self) -> None:
254
+ """
255
+ Clear the step caches that this ExperimentHandler uses.
256
+ """
257
+ self._queried_all_steps = False
258
+ self._steps.clear()
259
+ self._steps_by_name.clear()
260
+ self._steps_by_id.clear()
261
+ self._step_options.clear()
262
+ self._step_updates.clear()
263
+
264
+ def clear_experiment_caches(self) -> None:
265
+ """
266
+ Clear the experiment information caches that this ExperimentHandler uses.
267
+ """
268
+ self._exp_record = None
269
+ self._exp_template = None
270
+ self._exp_options = {}
271
+
272
+ def clear_tab_caches(self) -> None:
273
+ """
274
+ Clear the tab caches that this ExperimentHandler uses.
275
+ """
276
+ self._queried_all_tabs = False
277
+ self._tabs.clear()
278
+ self._tabs_by_id.clear()
279
+ self._tabs_by_name.clear()
280
+
281
+ def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> ElnEntryStep:
282
+ """
283
+ Add the given entry to the cache of steps for this experiment. This is necessary in order for certain methods to
284
+ work. You should only need to do this if you have created a new entry in your code using a method outside
285
+ of this ExperimentHandler.
286
+
287
+ :param entry: The entry to add to the cache.
288
+ :return: The entry that was added to the cache as an ElnEntryStep.
289
+ """
290
+ if isinstance(entry, ExperimentEntry):
291
+ entry = ElnEntryStep(self._protocol, entry)
292
+ self._steps.append(entry)
293
+ self._steps_by_name.update({entry.get_name(): entry})
294
+ self._steps_by_id.update({entry.get_id(): entry})
295
+ # Skipping the options cache. The get_step_options method will update the cache when necessary.
296
+ return entry
297
+
298
+ def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> list[ElnEntryStep]:
299
+ """
300
+ Add the given entries to the cache of steps for this experiment. This is necessary in order for certain methods
301
+ to work. You should only need to do this if you have created a new entry in your code using a method outside
302
+ of this ExperimentHandler.
303
+
304
+ :param entries: The entries to add to the cache.
305
+ :return: The entries that were added to the cache as ElnEntrySteps.
306
+ """
307
+ new_entries: list[ElnEntryStep] = []
308
+ for entry in entries:
309
+ new_entries.append(self.add_entry_to_caches(entry))
310
+ return new_entries
311
+
312
+ def add_tab_to_cache(self, tab: ElnExperimentTab) -> None:
313
+ """
314
+ Add the given tab to the cache of tabs for this experiment. This is necessary in order for certain methods
315
+ to work properly. You should only need to do this if you have created a new tab in your code using a method
316
+ outside of this ExperimentHandler.
317
+
318
+ :param tab: The tab to add to the cache.
319
+ """
320
+ self._tabs.append(tab)
321
+ self._tabs.sort(key=lambda t: t.tab_order)
322
+ self._tabs_by_id[tab.tab_id] = tab
323
+ self._tabs_by_name[tab.tab_name] = tab
324
+
187
325
  # FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
188
326
  @staticmethod
189
327
  def create_experiment(context: SapioWebhookContext,
@@ -211,22 +349,10 @@ class ExperimentHandler:
211
349
  :param active_templates_only: Whether only active templates should be queried for.
212
350
  :return: The newly created experiment.
213
351
  """
214
- template_query = TemplateExperimentQueryPojo(latest_version_only=(template_version is None),
215
- active_templates_only=active_templates_only)
216
- templates: list[ElnTemplate] = context.eln_manager.get_template_experiment_list(template_query)
217
- launch_template: ElnTemplate | None = None
218
- for template in templates:
219
- if template.template_name != template_name:
220
- continue
221
- if template_version is not None and template.template_version != template_version:
222
- continue
223
- launch_template = template
224
- break
225
- if launch_template is None:
226
- raise SapioException(f"No template with the name \"{template_name}\"" +
227
- ("" if template_version is None else f" and the version {template_version}") +
228
- f" found.")
229
-
352
+ launch_template: ElnTemplate = ExperimentCacheManager(context).get_experiment_template(template_name,
353
+ active_templates_only,
354
+ template_version,
355
+ first_match=True)
230
356
  if experiment_name is None:
231
357
  experiment_name: str = launch_template.display_name
232
358
  if parent_record is not None:
@@ -239,7 +365,8 @@ class ExperimentHandler:
239
365
  template_name: str,
240
366
  experiment_name: str | None = None,
241
367
  parent_record: SapioRecord | None = None, *,
242
- template_version: int | None = None, active_templates_only: bool = True) -> SapioWebhookResult:
368
+ template_version: int | None = None,
369
+ active_templates_only: bool = True) -> SapioWebhookResult:
243
370
  """
244
371
  Create a SapioWebhookResult that, when returned by a webhook handler, sends the user to a new experiment of the
245
372
  input template name.
@@ -275,24 +402,24 @@ class ExperimentHandler:
275
402
  when the experiment template doesn't exist.
276
403
  :return: This experiment's template. None if it has no template.
277
404
  """
278
- template_id: int | None = self.__eln_exp.template_id
405
+ template_id: int | None = self._eln_exp.template_id
279
406
  if template_id is None:
280
- self.__exp_template = None
407
+ self._exp_template = None
281
408
  if exception_on_none:
282
- raise SapioException(f"Experiment with ID {self.__exp_id} has no template ID.")
409
+ raise SapioException(f"Experiment with ID {self._exp_id} has no template ID.")
283
410
  return None
284
411
 
285
- if not hasattr(self, "_ExperimentHandler__exp_template"):
412
+ if not hasattr(self, "_exp_template"):
286
413
  # PR-46504: Allow inactive and non-latest version templates to be queried.
287
414
  query = TemplateExperimentQueryPojo(template_id_white_list=[template_id],
288
415
  active_templates_only=False,
289
416
  latest_version_only=False)
290
- templates: list[ElnTemplate] = self.__eln_man.get_template_experiment_list(query)
417
+ templates: list[ElnTemplate] = self._eln_man.get_template_experiment_list(query)
291
418
  # PR-46504: Set the exp_template to None if there are no results.
292
- self.__exp_template = templates[0] if templates else None
293
- if self.__exp_template is None and exception_on_none:
294
- raise SapioException(f"Experiment template not found for experiment with ID {self.__exp_id}.")
295
- return self.__exp_template
419
+ self._exp_template = templates[0] if templates else None
420
+ if self._exp_template is None and exception_on_none:
421
+ raise SapioException(f"Experiment template not found for experiment with ID {self._exp_id}.")
422
+ return self._exp_template
296
423
 
297
424
  # CR-46104: Change get_template_name to behave like NotebookProtocolImpl.getTemplateName (i.e. first see if the
298
425
  # experiment template exists, and if not, see if the experiment record exists, instead of only checking the
@@ -309,18 +436,18 @@ class ExperimentHandler:
309
436
  when the template name doesn't exist.
310
437
  :return: The template name of the current experiment. None if it has no template name.
311
438
  """
312
- if not hasattr(self, "_ExperimentHandler__exp_template"):
439
+ if not hasattr(self, "_exp_template"):
313
440
  self.get_experiment_template(False)
314
- if self.__exp_template is None and not hasattr(self, "_ExperimentHandler__exp_record"):
441
+ if self._exp_template is None and not hasattr(self, "_exp_record"):
315
442
  self.get_experiment_record(False)
316
443
 
317
444
  name: str | None = None
318
- if self.__exp_template is not None:
319
- name = self.__exp_template.template_name
320
- elif self.__exp_record is not None:
321
- name = self.__exp_record.get_field_value("TemplateExperimentName")
445
+ if self._exp_template is not None:
446
+ name = self._exp_template.template_name
447
+ elif self._exp_record is not None:
448
+ name = self._exp_record.get_field_value("TemplateExperimentName")
322
449
  if name is None and exception_on_none:
323
- raise SapioException(f"Template name not found for experiment with ID {self.__exp_id}.")
450
+ raise SapioException(f"Template name not found for experiment with ID {self._exp_id}.")
324
451
  return name
325
452
 
326
453
  def get_experiment_record(self, exception_on_none: bool = True) -> DataRecord | None:
@@ -331,21 +458,23 @@ class ExperimentHandler:
331
458
  when the experiment record doesn't exist.
332
459
  :return: The data record for this experiment. None if it has no record.
333
460
  """
334
- if not hasattr(self, "_ExperimentHandler__exp_record"):
335
- self.__exp_record = self.__protocol.get_record()
336
- if self.__exp_record is None and exception_on_none:
337
- raise SapioException(f"Experiment record not found for experiment with ID {self.__exp_id}.")
338
- return self.__exp_record
461
+ if not hasattr(self, "_exp_record"):
462
+ self._exp_record = self._protocol.get_record()
463
+ if self._exp_record is None and exception_on_none:
464
+ raise SapioException(f"Experiment record not found for experiment with ID {self._exp_id}.")
465
+ return self._exp_record
339
466
 
340
- def get_experiment_model(self, wrapper_type: type[WrappedType]) -> WrappedType:
467
+ # CR-47491: Support not providing a wrapper type to receive PyRecordModels instead of WrappedRecordModels.
468
+ def get_experiment_model(self, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
341
469
  """
342
470
  Query for the data record of this experiment and wrap it as a record model with the given wrapper.
343
471
  The returned record is cached by the ExperimentHandler.
344
472
 
345
- :param wrapper_type: The record model wrapper to use.
473
+ :param wrapper_type: The record model wrapper to use. If not provided, the record is returned as a
474
+ PyRecordModel instead of a WrappedRecordModel.
346
475
  :return: The record model for this experiment.
347
476
  """
348
- return self.__inst_man.add_existing_record_of_type(self.get_experiment_record(), wrapper_type)
477
+ return self._rec_handler.wrap_model(self.get_experiment_record(), wrapper_type)
349
478
 
350
479
  def update_experiment(self,
351
480
  experiment_name: str | None = None,
@@ -367,14 +496,14 @@ class ExperimentHandler:
367
496
  criteria.new_experiment_name = experiment_name
368
497
  criteria.new_experiment_status = experiment_status
369
498
  criteria.experiment_option_map = experiment_option_map
370
- self.__eln_man.update_notebook_experiment(self.__exp_id, criteria)
499
+ self._eln_man.update_notebook_experiment(self._exp_id, criteria)
371
500
 
372
501
  if experiment_name is not None:
373
- self.__eln_exp.notebook_experiment_name = experiment_name
502
+ self._eln_exp.notebook_experiment_name = experiment_name
374
503
  if experiment_status is not None:
375
- self.__eln_exp.notebook_experiment_status = experiment_status
504
+ self._eln_exp.notebook_experiment_status = experiment_status
376
505
  if experiment_option_map is not None:
377
- self.__exp_options = experiment_option_map
506
+ self._exp_options = experiment_option_map
378
507
 
379
508
  def get_experiment_option(self, option: str) -> str:
380
509
  """
@@ -399,7 +528,10 @@ class ExperimentHandler:
399
528
 
400
529
  :return: The map of options for this experiment.
401
530
  """
402
- return self.__get_experiment_options()
531
+ if hasattr(self, "_exp_options"):
532
+ return self._exp_options
533
+ self._exp_options = self._eln_man.get_notebook_experiment_options(self._exp_id)
534
+ return self._exp_options
403
535
 
404
536
  def add_experiment_options(self, mapping: Mapping[str, str]) -> None:
405
537
  """
@@ -424,7 +556,7 @@ class ExperimentHandler:
424
556
 
425
557
  :return: True if the experiment's status is Completed or CompletedApproved. False otherwise.
426
558
  """
427
- return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_COMPLETE_STATUSES
559
+ return self._eln_exp.notebook_experiment_status in self._EXPERIMENT_COMPLETE_STATUSES
428
560
 
429
561
  def experiment_is_canceled(self) -> bool:
430
562
  """
@@ -432,7 +564,7 @@ class ExperimentHandler:
432
564
 
433
565
  :return: True if the experiment's status is Canceled. False otherwise.
434
566
  """
435
- return self.__eln_exp.notebook_experiment_status == ElnExperimentStatus.Canceled
567
+ return self._eln_exp.notebook_experiment_status == ElnExperimentStatus.Canceled
436
568
 
437
569
  def experiment_is_locked(self) -> bool:
438
570
  """
@@ -441,29 +573,34 @@ class ExperimentHandler:
441
573
  :return: True if the experiment's status is Completed, CompletedApproved, Canceled, LockedAwaitingApproval,
442
574
  or LockedRejected. False otherwise.
443
575
  """
444
- return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_LOCKED_STATUSES
576
+ return self._eln_exp.notebook_experiment_status in self._EXPERIMENT_LOCKED_STATUSES
445
577
 
446
578
  def complete_experiment(self) -> None:
447
579
  """
448
580
  Set the experiment's status to Completed. Makes a webservice call to update the experiment. Checks if the
449
581
  experiment is already completed, and does nothing if so.
582
+
583
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Complete Experiment"
584
+ toolbar button. This includes moving the in process samples forward to the next step in the process.
450
585
  """
451
586
  if not self.experiment_is_complete():
452
- self.__protocol.complete_protocol()
453
- self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Completed
587
+ self._protocol.complete_protocol()
588
+ self._eln_exp.notebook_experiment_status = ElnExperimentStatus.Completed
454
589
 
455
590
  def cancel_experiment(self) -> None:
456
591
  """
457
592
  Set the experiment's status to Canceled. Makes a webservice call to update the experiment. Checks if the
458
593
  experiment is already canceled, and does nothing if so.
459
594
 
460
- NOTE: This will not run the usual logic around canceling an experiment that you'd see if canceling the
461
- experiment using the "Cancel Experiment" toolbar button, such as moving in process samples back to the queue,
462
- as those changes are tied to the button instead of being on the experiment status change.
595
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Cancel Experiment"
596
+ toolbar button. This includes moving the in process samples back into the process queue for the current step.
597
+
598
+ On version 24.12 and earlier, this was not the case, as the process tracking logic was tied to the button
599
+ instead of being on the experiment status change.
463
600
  """
464
601
  if not self.experiment_is_canceled():
465
- self.__protocol.cancel_protocol()
466
- self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Canceled
602
+ self._protocol.cancel_protocol()
603
+ self._eln_exp.notebook_experiment_status = ElnExperimentStatus.Canceled
467
604
 
468
605
  def step_exists(self, step_name: str) -> bool:
469
606
  """
@@ -489,14 +626,14 @@ class ExperimentHandler:
489
626
  """
490
627
  return all([x is not None for x in self.get_steps(step_names, False)])
491
628
 
492
- def get_step(self, step_name: str, exception_on_none: bool = True) -> ElnEntryStep | None:
629
+ def get_step(self, step_name: str | int, exception_on_none: bool = True) -> ElnEntryStep | None:
493
630
  """
494
631
  Get the step of the given name from the experiment.
495
632
 
496
633
  If no step functions have been called before and a step is being searched for by name, queries for the
497
634
  list of steps in the experiment and caches them.
498
635
 
499
- :param step_name: The name for the step to return.
636
+ :param step_name: The name or ID for the step to return.
500
637
  :param exception_on_none: If false, returns None if the entry can't be found. If true, raises an exception
501
638
  when the named entry doesn't exist in the experiment.
502
639
  :return: An ElnEntrySteps matching the provided name. If there is no match and no exception is to be thrown,
@@ -504,31 +641,32 @@ class ExperimentHandler:
504
641
  """
505
642
  return self.get_steps([step_name], exception_on_none)[0]
506
643
 
507
- def get_steps(self, step_names: Iterable[str], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
644
+ def get_steps(self, step_names: Iterable[str | int], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
508
645
  """
509
646
  Get a list of steps of the given names from the experiment, sorted in the same order as the names are provided.
510
647
 
511
648
  If no step functions have been called before and a step is being searched for by name, queries for the
512
649
  list of steps in the experiment and caches them.
513
650
 
514
- :param step_names: A list of names for the entries to return and the order to return them in.
651
+ :param step_names: A list of names or IDs for the entries to return and the order to return them in.
515
652
  :param exception_on_none: If false, returns None for entries that can't be found. If true, raises an exception
516
653
  when the named entry doesn't exist in the experiment.
517
654
  :return: A list of ElnEntrySteps matching the provided names in the order they were provided in. If there is no
518
655
  match for a given step and no exception is to be thrown, returns None for that step.
519
656
  """
520
657
  ret_list: list[ElnEntryStep | None] = []
521
- for name in step_names:
658
+ for step_id in step_names:
522
659
  # If we haven't queried the system for all steps in the experiment yet, then the reason that a step is
523
660
  # missing may be because it wasn't in the webhook context. Therefore, query all steps and then check
524
661
  # if the step name is still missing from the experiment before potentially throwing an exception.
525
- if self.__queried_all_steps is False and name not in self.__steps:
526
- self.__queried_all_steps = True
527
- self.__steps.update({step.get_name(): step for step in self.__protocol.get_sorted_step_list()})
528
-
529
- step: ElnEntryStep = self.__steps.get(name)
662
+ if self._queried_all_steps is False and step_id not in self._steps_by_name and step_id not in self._steps_by_id:
663
+ self._query_all_steps()
664
+ if isinstance(step_id, str):
665
+ step: ElnEntryStep = self._steps_by_name.get(step_id)
666
+ else:
667
+ step: ElnEntryStep = self._steps_by_id.get(step_id)
530
668
  if step is None and exception_on_none is True:
531
- raise SapioException(f"ElnEntryStep of name \"{name}\" not found in experiment with ID {self.__exp_id}.")
669
+ raise SapioException(f"ElnEntryStep of name \"{step_id}\" not found in experiment with ID {self._exp_id}.")
532
670
  ret_list.append(step)
533
671
  return ret_list
534
672
 
@@ -542,15 +680,35 @@ class ExperimentHandler:
542
680
  a data type name or wrapper is given, only returns entries that match that data type name or wrapper.
543
681
  :return: Every entry in the experiment in order of appearance that match the provided data type, if any.
544
682
  """
545
- if self.__queried_all_steps is False:
546
- self.__queried_all_steps = True
547
- self.__steps.update({step.get_name(): step for step in self.__protocol.get_sorted_step_list()})
548
- all_steps: list[ElnEntryStep] = self.__protocol.get_sorted_step_list()
683
+ if self._queried_all_steps is False:
684
+ self._query_all_steps()
685
+ else:
686
+ # Re-sort the steps in case any new steps were added before the last time that this was called.
687
+ def sort_steps(step: ElnEntryStep) -> tuple:
688
+ entry = step.eln_entry
689
+ tab_order: int = self.get_tab(entry.notebook_experiment_tab_id).tab_order
690
+ entry_order: int = entry.order
691
+ column_order: int = entry.column_order
692
+ return tab_order, entry_order, column_order
693
+
694
+ self._steps.sort(key=sort_steps)
695
+ all_steps: list[ElnEntryStep] = self._steps
549
696
  if data_type is None:
550
697
  return all_steps
551
698
  data_type: str = AliasUtil.to_data_type_name(data_type)
552
699
  return [x for x in all_steps if data_type in x.get_data_type_names()]
553
700
 
701
+ def _query_all_steps(self) -> None:
702
+ """
703
+ Query the system for every step in the experiment and cache them.
704
+ """
705
+ self._queried_all_steps = True
706
+ self._protocol.invalidate()
707
+ self._steps = self._protocol.get_sorted_step_list()
708
+ for step in self._steps:
709
+ self._steps_by_name[step.get_name()] = step
710
+ self._steps_by_id[step.get_id()] = step
711
+
554
712
  def get_step_by_option(self, key: str, value: str | None = None) -> ElnEntryStep:
555
713
  """
556
714
  Retrieve the step in this experiment that contains an entry option with the provided key and value.
@@ -599,7 +757,8 @@ class ExperimentHandler:
599
757
  """
600
758
  return self.__to_eln_step(step).get_records()
601
759
 
602
- def get_step_models(self, step: Step, wrapper_type: type[WrappedType]) -> list[WrappedType]:
760
+ def get_step_models(self, step: Step, wrapper_type: type[WrappedType] | None = None) \
761
+ -> list[WrappedType] | list[PyRecordModel]:
603
762
  """
604
763
  Query for the data records for the given step and wrap them as record models with the given type. The returned
605
764
  records are not cached by the ExperimentHandler.
@@ -611,10 +770,11 @@ class ExperimentHandler:
611
770
  The step to get the data records for.
612
771
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
613
772
  If given a name, throws an exception if no step of the given name exists in the experiment.
614
- :param wrapper_type: The record model wrapper to use.
773
+ :param wrapper_type: The record model wrapper to use. If not provided, the records are returned as
774
+ PyRecordModels instead of WrappedRecordModels.
615
775
  :return: The record models for the given step.
616
776
  """
617
- return self.__inst_man.add_existing_records_of_type(self.get_step_records(step), wrapper_type)
777
+ return self._rec_handler.wrap_models(self.get_step_records(step), wrapper_type)
618
778
 
619
779
  def add_step_records(self, step: Step, records: Iterable[SapioRecord]) -> None:
620
780
  """
@@ -637,8 +797,9 @@ class ExperimentHandler:
637
797
  return
638
798
  dt: str = AliasUtil.to_singular_data_type_name(records)
639
799
  if ElnBaseDataType.is_base_data_type(dt):
640
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
641
- f"Use add_eln_rows or add_sample_details instead.")
800
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. ELN "
801
+ f"records that are committed to the system will automatically appear in the ELN entry "
802
+ f"with the matching data type name.")
642
803
  if dt != step.get_data_type_names()[0]:
643
804
  raise SapioException(f"Cannot add {dt} records to entry {step.get_name()} of type "
644
805
  f"{step.get_data_type_names()[0]}.")
@@ -664,8 +825,9 @@ class ExperimentHandler:
664
825
  return
665
826
  dt: str = AliasUtil.to_singular_data_type_name(records)
666
827
  if ElnBaseDataType.is_base_data_type(dt):
667
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
668
- f"Use remove_eln_rows or remove_sample_details instead.")
828
+ # CR-47532: Add remove_step_records support for Experiment Detail and Sample Detail entries.
829
+ self.remove_eln_rows(step, records)
830
+ return
669
831
  if dt != step.get_data_type_names()[0]:
670
832
  raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
671
833
  f"{step.get_data_type_names()[0]}.")
@@ -694,9 +856,14 @@ class ExperimentHandler:
694
856
  step = self.__to_eln_step(step)
695
857
  if records:
696
858
  dt: str = AliasUtil.to_singular_data_type_name(records)
859
+ # CR-47532: Add set_step_records support for Experiment Detail and Sample Detail entries.
697
860
  if ElnBaseDataType.is_base_data_type(dt):
698
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
699
- f"Use add_eln_rows or add_sample_details instead.")
861
+ remove_rows: list[PyRecordModel] = []
862
+ for record in self.get_step_models(step):
863
+ if record not in records:
864
+ remove_rows.append(record)
865
+ self.remove_eln_rows(step, remove_rows)
866
+ return
700
867
  if dt != step.get_data_type_names()[0]:
701
868
  raise SapioException(f"Cannot set {dt} records for entry {step.get_name()} of type "
702
869
  f"{step.get_data_type_names()[0]}.")
@@ -718,10 +885,30 @@ class ExperimentHandler:
718
885
  The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
719
886
  """
720
887
  self.set_step_records(step, [record])
888
+ step = self.__to_eln_step(step)
889
+ if isinstance(step.eln_entry, ExperimentFormEntry):
890
+ step.eln_entry.record_id = AliasUtil.to_data_record(record).record_id
721
891
 
722
892
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
893
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
894
+ """
895
+ Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
896
+ until a record manager store and commit has occurred.
897
+
898
+ If no step functions have been called before and a step is being searched for by name, queries for the
899
+ list of steps in the experiment and caches them.
900
+
901
+ :param step:
902
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
903
+ If given a name, throws an exception if no step of the given name exists in the experiment.
904
+ :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
905
+ an unwrapped PyRecordModel.
906
+ :return: The newly created row.
907
+ """
908
+ return self.add_eln_rows(step, 1, wrapper_type)[0]
909
+
723
910
  def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
724
- -> list[PyRecordModel | WrappedType]:
911
+ -> list[WrappedType] | list[PyRecordModel]:
725
912
  """
726
913
  Add rows to an ELNExperimentDetail or ELNSampleDetail table entry. The rows will not appear in the system
727
914
  until a record manager store and commit has occurred.
@@ -743,15 +930,69 @@ class ExperimentHandler:
743
930
  dt: str = step.get_data_type_names()[0]
744
931
  if not ElnBaseDataType.is_eln_type(dt):
745
932
  raise SapioException("The provided step is not an ELN data type entry.")
746
- records: list[PyRecordModel] = self.__inst_man.add_new_records(dt, count)
933
+ records: list[PyRecordModel] = self._inst_man.add_new_records(dt, count)
747
934
  if wrapper_type:
748
- return self.__inst_man.wrap_list(records, wrapper_type)
935
+ return self._inst_man.wrap_list(records, wrapper_type)
749
936
  return records
750
937
 
751
- def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> PyRecordModel | WrappedType:
938
+ def add_sample_detail(self, step: Step, sample: RecordModel,
939
+ wrapper_type: type[WrappedType] | None = None) \
940
+ -> WrappedType | PyRecordModel:
752
941
  """
753
- Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
754
- until a record manager store and commit has occurred.
942
+ Add a sample detail to a sample detail entry while relating it to the input sample record.
943
+
944
+ :param step:
945
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
946
+ If given a name, throws an exception if no step of the given name exists in the experiment.
947
+ :param sample: The sample record to add the sample detail to.
948
+ :param wrapper_type: Optionally wrap the sample detail in a record model wrapper. If not provided, returns
949
+ an unwrapped PyRecordModel.
950
+ :return: The newly created sample detail.
951
+ """
952
+ return self.add_sample_details(step, [sample], wrapper_type)[0]
953
+
954
+ def add_sample_details(self, step: Step, samples: Iterable[RecordModel],
955
+ wrapper_type: type[WrappedType] | None = None) \
956
+ -> list[WrappedType] | list[PyRecordModel]:
957
+ """
958
+ Add sample details to a sample details entry while relating them to the input sample records.
959
+
960
+ :param step:
961
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
962
+ If given a name, throws an exception if no step of the given name exists in the experiment.
963
+ :param samples: The sample records to add the sample details to.
964
+ :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
965
+ an unwrapped PyRecordModel.
966
+ :return: The newly created sample details. The indices of the samples in the input list match the index of the
967
+ sample details in this list that they are related to.
968
+ """
969
+ step = self.__to_eln_step(step)
970
+ if step.eln_entry.entry_type != ElnEntryType.Table:
971
+ raise SapioException("The provided step is not a table entry.")
972
+ dt: str = step.get_data_type_names()[0]
973
+ if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
974
+ raise SapioException("The provided step is not an ELNSampleDetail entry.")
975
+ records: list[PyRecordModel] = []
976
+ for sample in samples:
977
+ if sample.data_type_name != "Sample":
978
+ raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
979
+ detail: PyRecordModel = sample.add(Child.create_by_name(dt))
980
+ detail.set_field_values({
981
+ "SampleId": sample.get_field_value("SampleId"),
982
+ "OtherSampleId": sample.get_field_value("OtherSampleId")
983
+ })
984
+ records.append(detail)
985
+ if wrapper_type:
986
+ return self._inst_man.wrap_list(records, wrapper_type)
987
+ return records
988
+
989
+ def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
990
+ """
991
+ Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
992
+ records in the system that match the entry's data type. This means that removing rows from an ELN data type
993
+ table entry is equivalent to deleting the records for the rows.
994
+
995
+ The row will not be deleted in the system until a record manager store and commit has occurred.
755
996
 
756
997
  If no step functions have been called before and a step is being searched for by name, queries for the
757
998
  list of steps in the experiment and caches them.
@@ -759,13 +1000,13 @@ class ExperimentHandler:
759
1000
  :param step:
760
1001
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
761
1002
  If given a name, throws an exception if no step of the given name exists in the experiment.
762
- :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
763
- an unwrapped PyRecordModel.
764
- :return: The newly created row.
1003
+ :param record:
1004
+ The record to remove from the given step.
1005
+ The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
765
1006
  """
766
- return self.add_eln_rows(step, 1, wrapper_type)[0]
1007
+ self.remove_eln_rows(step, [record])
767
1008
 
768
- def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
1009
+ def remove_eln_rows(self, step: Step, records: Iterable[SapioRecord]) -> None:
769
1010
  """
770
1011
  Remove rows from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
771
1012
  records in the system that match the entry's data type. This means that removing rows from an ELN data type
@@ -802,64 +1043,11 @@ class ExperimentHandler:
802
1043
  else:
803
1044
  record.delete()
804
1045
  if data_records:
805
- record_models: list[PyRecordModel] = self.__inst_man.add_existing_records(data_records)
1046
+ record_models: list[PyRecordModel] = self._inst_man.add_existing_records(data_records)
806
1047
  for record in record_models:
807
1048
  record.delete()
808
1049
 
809
- def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
810
- """
811
- Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
812
- records in the system that match the entry's data type. This means that removing rows from an ELN data type
813
- table entry is equivalent to deleting the records for the rows.
814
-
815
- The row will not be deleted in the system until a record manager store and commit has occurred.
816
-
817
- If no step functions have been called before and a step is being searched for by name, queries for the
818
- list of steps in the experiment and caches them.
819
-
820
- :param step:
821
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
822
- If given a name, throws an exception if no step of the given name exists in the experiment.
823
- :param record:
824
- The record to remove from the given step.
825
- The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
826
- """
827
- self.remove_eln_rows(step, [record])
828
-
829
- def add_sample_details(self, step: Step, samples: list[RecordModel], wrapper_type: type[WrappedType]) \
830
- -> list[PyRecordModel | WrappedType]:
831
- """
832
- Add sample details to a sample details entry while relating them to the input sample records.
833
-
834
- :param step:
835
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
836
- If given a name, throws an exception if no step of the given name exists in the experiment.
837
- :param samples: The sample records to add the sample details to.
838
- :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
839
- an unwrapped PyRecordModel.
840
- :return: The newly created sample details. The indices of the samples in the input list match the index of the
841
- sample details in this list that they are related to.
842
- """
843
- step = self.__to_eln_step(step)
844
- if step.eln_entry.entry_type != ElnEntryType.Table:
845
- raise SapioException("The provided step is not a table entry.")
846
- dt: str = step.get_data_type_names()[0]
847
- if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
848
- raise SapioException("The provided step is not an ELNSampleDetail entry.")
849
- records: list[PyRecordModel] = []
850
- for sample in samples:
851
- if sample.data_type_name != "Sample":
852
- raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
853
- detail: PyRecordModel = sample.add(Child.create_by_name(dt))
854
- detail.set_field_values({
855
- "SampleId": sample.get_field_value("SampleId"),
856
- "OtherSampleId": sample.get_field_value("OtherSampleId")
857
- })
858
- records.append(detail)
859
- if wrapper_type:
860
- return self.__inst_man.wrap_list(records, wrapper_type)
861
- return records
862
-
1050
+ # noinspection PyPep8Naming
863
1051
  def update_step(self, step: Step,
864
1052
  entry_name: str | None = None,
865
1053
  related_entry_set: Iterable[int] | None = None,
@@ -926,8 +1114,227 @@ class ExperimentHandler:
926
1114
  If you wish to add options to the existing map of options that an entry has, use the
927
1115
  add_step_options method.
928
1116
  """
1117
+ # FR-47468: Deprecating this since the parameters are ordered. The new method requires keyword parameters, so
1118
+ # that we can add new parameters wherever we want without breaking existing code.
1119
+ warnings.warn("Update step is deprecated. Use force_entry_update_params instead.",
1120
+ DeprecationWarning)
1121
+ self.force_step_update_params(step,
1122
+ entry_name=entry_name,
1123
+ related_entry_set=related_entry_set,
1124
+ dependency_set=dependency_set,
1125
+ entry_status=entry_status,
1126
+ order=order,
1127
+ description=description,
1128
+ requires_grabber_plugin=requires_grabber_plugin,
1129
+ is_initialization_required=is_initialization_required,
1130
+ notebook_experiment_tab_id=notebook_experiment_tab_id,
1131
+ entry_height=entry_height,
1132
+ column_order=column_order,
1133
+ column_span=column_span,
1134
+ is_removable=is_removable,
1135
+ is_renamable=is_renamable,
1136
+ source_entry_id=source_entry_id,
1137
+ clear_source_entry_id=clear_source_entry_id,
1138
+ is_hidden=is_hidden,
1139
+ is_static_view=is_static_View,
1140
+ is_shown_in_template=is_shown_in_template,
1141
+ template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1142
+ clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1143
+ entry_options_map=entry_options_map)
1144
+
1145
+ # FR-47468: Some functions that can help with entry updates.
1146
+ def force_step_update_params(self, step: Step, *,
1147
+ entry_name: str | None = None,
1148
+ related_entry_set: Iterable[int] | None = None,
1149
+ dependency_set: Iterable[int] | None = None,
1150
+ entry_status: ExperimentEntryStatus | None = None,
1151
+ order: int | None = None,
1152
+ description: str | None = None,
1153
+ requires_grabber_plugin: bool | None = None,
1154
+ is_initialization_required: bool | None = None,
1155
+ notebook_experiment_tab_id: int | None = None,
1156
+ entry_height: int | None = None,
1157
+ column_order: int | None = None,
1158
+ column_span: int | None = None,
1159
+ is_removable: bool | None = None,
1160
+ is_renamable: bool | None = None,
1161
+ source_entry_id: int | None = None,
1162
+ clear_source_entry_id: bool | None = None,
1163
+ is_hidden: bool | None = None,
1164
+ is_static_view: bool | None = None,
1165
+ is_shown_in_template: bool | None = None,
1166
+ template_item_fulfilled_timestamp: int | None = None,
1167
+ clear_template_item_fulfilled_timestamp: bool | None = None,
1168
+ entry_options_map: dict[str, str] | None = None) -> None:
1169
+ """
1170
+ Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
1171
+ of the Step that is being updated.
1172
+
1173
+ Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1174
+
1175
+ If no step functions have been called before and a step is being searched for by name, queries for the
1176
+ list of steps in the experiment and caches them.
1177
+
1178
+ :param step:
1179
+ The entry step to update.
1180
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1181
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1182
+ :param entry_name: The new name of this entry.
1183
+ :param related_entry_set: The new set of entry IDs for the entries that are related (implicitly dependent) to
1184
+ this entry. Completely overwrites the existing related entries.
1185
+ :param dependency_set: The new set of entry IDs for the entries that are dependent (explicitly dependent) on
1186
+ this entry. Completely overwrites the existing dependent entries.
1187
+ :param entry_status: The new status of this entry.
1188
+ :param order: The row order of this entry in its tab.
1189
+ :param description: The new description of this entry.
1190
+ :param requires_grabber_plugin: Whether this entry's initialization is handled by a grabber plugin. If true,
1191
+ then is_initialization_required is forced to true by the server.
1192
+ :param is_initialization_required: Whether the user is required to manually initialize this entry.
1193
+ :param notebook_experiment_tab_id: The ID of the tab that this entry should appear on.
1194
+ :param entry_height: The height of this entry.
1195
+ :param column_order: The column order of this entry.
1196
+ :param column_span: How many columns this entry spans.
1197
+ :param is_removable: Whether this entry can be removed by the user.
1198
+ :param is_renamable: Whether this entry can be renamed by the user.
1199
+ :param source_entry_id: The ID of this entry from its template.
1200
+ :param clear_source_entry_id: True if the source entry ID should be cleared.
1201
+ :param is_hidden: Whether this entry is hidden from the user.
1202
+ :param is_static_view: Whether this entry is static. Static entries are uneditable and shared across all
1203
+ experiments of the same template.
1204
+ :param is_shown_in_template: Whether this entry is saved to and shown in the experiment's template.
1205
+ :param template_item_fulfilled_timestamp: A timestamp in milliseconds for when this entry was initialized.
1206
+ :param clear_template_item_fulfilled_timestamp: True if the template item fulfilled timestamp should be cleared,
1207
+ uninitializing the entry.
1208
+ :param entry_options_map:
1209
+ The new map of options for this entry. Completely overwrites the existing options map.
1210
+ Any changes to the entry options will update this ExperimentHandler's cache of entry options.
1211
+ If you wish to add options to the existing map of options that an entry has, use the
1212
+ add_step_options method.
1213
+ """
1214
+ update = self._criteria_from_params(step,
1215
+ entry_name=entry_name,
1216
+ related_entry_set=related_entry_set,
1217
+ dependency_set=dependency_set,
1218
+ entry_status=entry_status,
1219
+ order=order,
1220
+ description=description,
1221
+ requires_grabber_plugin=requires_grabber_plugin,
1222
+ is_initialization_required=is_initialization_required,
1223
+ notebook_experiment_tab_id=notebook_experiment_tab_id,
1224
+ entry_height=entry_height,
1225
+ column_order=column_order,
1226
+ column_span=column_span,
1227
+ is_removable=is_removable,
1228
+ is_renamable=is_renamable,
1229
+ source_entry_id=source_entry_id,
1230
+ clear_source_entry_id=clear_source_entry_id,
1231
+ is_hidden=is_hidden,
1232
+ is_static_view=is_static_view,
1233
+ is_shown_in_template=is_shown_in_template,
1234
+ template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1235
+ clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1236
+ entry_options_map=entry_options_map)
1237
+ self.force_step_update(step, update)
1238
+
1239
+ def force_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1240
+ """
1241
+ Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
1242
+ of the Step that is being updated.
1243
+
1244
+ Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1245
+
1246
+ If no step functions have been called before and a step is being searched for by name, queries for the
1247
+ list of steps in the experiment and caches them.
1248
+
1249
+ :param step: The step to update.
1250
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1251
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1252
+ :param update: The update to make to the step.
1253
+ """
1254
+ step = self.__to_eln_step(step)
1255
+ self._eln_man.update_experiment_entry(self._exp_id, step.get_id(), update)
1256
+ self._update_entry_details(step, update)
1257
+
1258
+ def store_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1259
+ """
1260
+ Store updates to be made to an entry in this experiment. The updates are not committed until
1261
+ commit_entry_updates is called.
1262
+
1263
+ If the same entry is updated multiple times before committing, the latest update will be merged on top of the
1264
+ previous updates; where the new update and old update conflict, the new update will take precedence.
1265
+
1266
+ If no step functions have been called before and a step is being searched for by name, queries for the
1267
+ list of steps in the experiment and caches them.
1268
+
1269
+ :param step: The step to update.
1270
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1271
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1272
+ :param update: The update to make to the step.
1273
+ """
1274
+ step = self.__to_eln_step(step)
1275
+ if step.eln_entry.entry_type != update.entry_type:
1276
+ raise SapioException(f"The provided step and update criteria are not of the same entry type. "
1277
+ f"The step is of type {step.eln_entry.entry_type} and the update criteria is of type "
1278
+ f"{update.entry_type}.")
1279
+ if step.get_id() in self._step_updates:
1280
+ self._merge_updates(update, self._step_updates[step.get_id()])
1281
+ else:
1282
+ self._step_updates[step.get_id()] = update
1283
+
1284
+ def store_step_updates(self, updates: dict[Step, AbstractElnEntryUpdateCriteria]) -> None:
1285
+ """
1286
+ Store updates to be made to multiple entries in this experiment. The updates are not committed until
1287
+ commit_entry_updates is called.
1288
+
1289
+ If the same entry is updated multiple times before committing, the latest update will be merged on top of the
1290
+ previous updates; where the new update and old update conflict, the new update will take precedence.
1291
+
1292
+ If no step functions have been called before and a step is being searched for by name, queries for the
1293
+ list of steps in the experiment and caches them.
1294
+
1295
+ :param updates: A dictionary of steps and their respective updates.
1296
+ """
1297
+ for step, update in updates.items():
1298
+ self.store_step_update(step, update)
1299
+
1300
+ def commit_step_updates(self) -> None:
1301
+ """
1302
+ Commit all the stored updates to the entries in this experiment. The updates are made in the order that they
1303
+ were stored.
1304
+ """
1305
+ self._eln_man.update_experiment_entries(self._exp_id, self._step_updates)
1306
+ for step_id, criteria in self._step_updates.items():
1307
+ self._update_entry_details(self._steps_by_id[step_id], criteria)
1308
+ self._step_updates.clear()
1309
+
1310
+ def _criteria_from_params(self, step: Step, *,
1311
+ entry_name: str | None = None,
1312
+ related_entry_set: Iterable[int] | None = None,
1313
+ dependency_set: Iterable[int] | None = None,
1314
+ entry_status: ExperimentEntryStatus | None = None,
1315
+ order: int | None = None,
1316
+ description: str | None = None,
1317
+ requires_grabber_plugin: bool | None = None,
1318
+ is_initialization_required: bool | None = None,
1319
+ notebook_experiment_tab_id: int | None = None,
1320
+ entry_height: int | None = None,
1321
+ column_order: int | None = None,
1322
+ column_span: int | None = None,
1323
+ is_removable: bool | None = None,
1324
+ is_renamable: bool | None = None,
1325
+ source_entry_id: int | None = None,
1326
+ clear_source_entry_id: bool | None = None,
1327
+ is_hidden: bool | None = None,
1328
+ is_static_view: bool | None = None,
1329
+ is_shown_in_template: bool | None = None,
1330
+ template_item_fulfilled_timestamp: int | None = None,
1331
+ clear_template_item_fulfilled_timestamp: bool | None = None,
1332
+ entry_options_map: dict[str, str] | None = None) -> AbstractElnEntryUpdateCriteria:
1333
+ """
1334
+ Create an abstract update criteria object from the provided parameters for the given step.
1335
+ """
929
1336
  step: ElnEntryStep = self.__to_eln_step(step)
930
- criteria = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1337
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
931
1338
 
932
1339
  # These two variables could be iterables that aren't lists. Convert them to plain
933
1340
  # lists, since that's what the update criteria is expecting.
@@ -936,81 +1343,154 @@ class ExperimentHandler:
936
1343
  if dependency_set is not None:
937
1344
  dependency_set = list(dependency_set)
938
1345
 
939
- criteria.entry_name = entry_name
940
- criteria.related_entry_set = related_entry_set
941
- criteria.dependency_set = dependency_set
942
- criteria.entry_status = entry_status
943
- criteria.order = order
944
- criteria.description = description
945
- criteria.requires_grabber_plugin = requires_grabber_plugin
946
- criteria.is_initialization_required = is_initialization_required
947
- criteria.notebook_experiment_tab_id = notebook_experiment_tab_id
948
- criteria.entry_height = entry_height
949
- criteria.column_order = column_order
950
- criteria.column_span = column_span
951
- criteria.is_removable = is_removable
952
- criteria.is_renamable = is_renamable
953
- criteria.source_entry_id = source_entry_id
954
- criteria.clear_source_entry_id = clear_source_entry_id
955
- criteria.is_hidden = is_hidden
956
- criteria.is_static_View = is_static_View
957
- criteria.is_shown_in_template = is_shown_in_template
958
- criteria.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
959
- criteria.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
960
- criteria.entry_options_map = entry_options_map
961
-
962
- self.__eln_man.update_experiment_entry(self.__exp_id, step.get_id(), criteria)
963
-
964
- # Update the cached information for this entry in case it's needed by the caller after updating.
1346
+ update.entry_name = entry_name
1347
+ update.related_entry_set = related_entry_set
1348
+ update.dependency_set = dependency_set
1349
+ update.entry_status = entry_status
1350
+ update.order = order
1351
+ update.description = description
1352
+ update.requires_grabber_plugin = requires_grabber_plugin
1353
+ update.is_initialization_required = is_initialization_required
1354
+ update.notebook_experiment_tab_id = notebook_experiment_tab_id
1355
+ update.entry_height = entry_height
1356
+ update.column_order = column_order
1357
+ update.column_span = column_span
1358
+ update.is_removable = is_removable
1359
+ update.is_renamable = is_renamable
1360
+ update.source_entry_id = source_entry_id
1361
+ update.clear_source_entry_id = clear_source_entry_id
1362
+ update.is_hidden = is_hidden
1363
+ update.is_static_View = is_static_view
1364
+ update.is_shown_in_template = is_shown_in_template
1365
+ update.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1366
+ update.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
1367
+ update.entry_options_map = entry_options_map
1368
+
1369
+ return update
1370
+
1371
+ @staticmethod
1372
+ def _merge_updates(new_update: AbstractElnEntryUpdateCriteria, old_update: AbstractElnEntryUpdateCriteria) -> None:
1373
+ """
1374
+ Merge the new update criteria onto the old update criteria. The new update will take precedence where there
1375
+ are conflicts.
1376
+ """
1377
+ for key, value in new_update.__dict__.items():
1378
+ if value is not None:
1379
+ old_update.__dict__[key] = value
1380
+
1381
+ def _update_entry_details(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1382
+ """
1383
+ Update the cached information for this entry in case it's needed by the caller after updating.
1384
+ """
965
1385
  entry: ExperimentEntry = step.eln_entry
966
- if entry_name is not None:
1386
+ if update.entry_name is not None:
967
1387
  # PR-46477 - Ensure that the previous name of the updated entry already existed in the cache.
968
- if entry.entry_name in self.__steps:
969
- self.__steps.pop(entry.entry_name)
970
- entry.entry_name = entry_name
971
- self.__steps.update({entry_name: step})
972
- if related_entry_set is not None:
973
- entry.related_entry_id_set = related_entry_set
974
- if dependency_set is not None:
975
- entry.dependency_set = dependency_set
976
- if entry_status is not None:
977
- entry.entry_status = entry_status
978
- if order is not None:
979
- entry.order = order
980
- if description is not None:
981
- entry.description = description
982
- if requires_grabber_plugin is not None:
983
- entry.requires_grabber_plugin = requires_grabber_plugin
984
- if is_initialization_required is not None:
985
- entry.is_initialization_required = is_initialization_required
986
- if notebook_experiment_tab_id is not None:
987
- entry.notebook_experiment_tab_id = notebook_experiment_tab_id
988
- if entry_height is not None:
989
- entry.entry_height = entry_height
990
- if column_order is not None:
991
- entry.column_order = column_order
992
- if column_span is not None:
993
- entry.column_span = column_span
994
- if is_removable is not None:
995
- entry.is_removable = is_removable
996
- if is_renamable is not None:
997
- entry.is_renamable = is_renamable
998
- if source_entry_id is not None:
999
- entry.source_entry_id = source_entry_id
1000
- if clear_source_entry_id is True:
1388
+ if entry.entry_name in self._steps_by_name:
1389
+ self._steps_by_name.pop(entry.entry_name)
1390
+ entry.entry_name = update.entry_name
1391
+ self._steps_by_name.update({update.entry_name: step})
1392
+ if update.related_entry_set is not None:
1393
+ entry.related_entry_id_set = update.related_entry_set
1394
+ if update.dependency_set is not None:
1395
+ entry.dependency_set = update.dependency_set
1396
+ if update.entry_status is not None:
1397
+ entry.entry_status = update.entry_status
1398
+ if update.order is not None:
1399
+ entry.order = update.order
1400
+ if update.description is not None:
1401
+ entry.description = update.description
1402
+ if update.requires_grabber_plugin is not None:
1403
+ entry.requires_grabber_plugin = update.requires_grabber_plugin
1404
+ if update.is_initialization_required is not None:
1405
+ entry.is_initialization_required = update.is_initialization_required
1406
+ if update.notebook_experiment_tab_id is not None:
1407
+ entry.notebook_experiment_tab_id = update.notebook_experiment_tab_id
1408
+ if update.entry_height is not None:
1409
+ entry.entry_height = update.entry_height
1410
+ if update.column_order is not None:
1411
+ entry.column_order = update.column_order
1412
+ if update.column_span is not None:
1413
+ entry.column_span = update.column_span
1414
+ if update.is_removable is not None:
1415
+ entry.is_removable = update.is_removable
1416
+ if update.is_renamable is not None:
1417
+ entry.is_renamable = update.is_renamable
1418
+ if update.source_entry_id is not None:
1419
+ entry.source_entry_id = update.source_entry_id
1420
+ if update.clear_source_entry_id is True:
1001
1421
  entry.source_entry_id = None
1002
- if is_hidden is not None:
1003
- entry.is_hidden = is_hidden
1004
- if is_static_View is not None:
1005
- entry.is_static_View = is_static_View
1006
- if is_shown_in_template is not None:
1007
- entry.is_shown_in_template = is_shown_in_template
1008
- if template_item_fulfilled_timestamp is not None:
1009
- entry.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1010
- if clear_template_item_fulfilled_timestamp is True:
1422
+ if update.is_hidden is not None:
1423
+ entry.is_hidden = update.is_hidden
1424
+ if update.is_static_View is not None:
1425
+ entry.is_static_View = update.is_static_View
1426
+ if update.is_shown_in_template is not None:
1427
+ entry.is_shown_in_template = update.is_shown_in_template
1428
+ if update.template_item_fulfilled_timestamp is not None:
1429
+ entry.template_item_fulfilled_timestamp = update.template_item_fulfilled_timestamp
1430
+ if update.clear_template_item_fulfilled_timestamp is True:
1011
1431
  entry.template_item_fulfilled_timestamp = None
1012
- if entry_options_map is not None:
1013
- self.__step_options.update({step.get_id(): entry_options_map})
1432
+ if update.entry_options_map is not None:
1433
+ self._step_options.update({step.get_id(): update.entry_options_map})
1434
+
1435
+ if isinstance(entry, ExperimentAttachmentEntry) and isinstance(update, ElnAttachmentEntryUpdateCriteria):
1436
+ if update.entry_attachment_list is not None:
1437
+ entry.entry_attachment_list = update.entry_attachment_list
1438
+ if update.record_id is not None:
1439
+ entry.record_id = update.record_id
1440
+ if update.attachment_name is not None:
1441
+ entry.attachment_name = update.attachment_name
1442
+ elif isinstance(entry, ExperimentDashboardEntry) and isinstance(update, ElnDashboardEntryUpdateCriteria):
1443
+ if update.dashboard_guid is not None:
1444
+ entry.dashboard_guid = update.dashboard_guid
1445
+ if update.dashboard_guid_list is not None:
1446
+ entry.dashboard_guid_list = update.dashboard_guid_list
1447
+ if update.data_source_entry_id is not None:
1448
+ entry.data_source_entry_id = update.data_source_entry_id
1449
+ elif isinstance(entry, ExperimentFormEntry) and isinstance(update, ElnFormEntryUpdateCriteria):
1450
+ if update.record_id is not None:
1451
+ entry.record_id = update.record_id
1452
+ if update.form_name_list is not None:
1453
+ entry.form_name_list = update.form_name_list
1454
+ if update.data_type_layout_name is not None:
1455
+ entry.data_type_layout_name = update.data_type_layout_name
1456
+ if update.field_set_id_list is not None:
1457
+ entry.field_set_id_list = update.field_set_id_list
1458
+ if update.extension_type_list is not None:
1459
+ entry.extension_type_list = update.extension_type_list
1460
+ if update.data_field_name_list is not None:
1461
+ entry.data_field_name_list = update.data_field_name_list
1462
+ if update.is_existing_field_removable is not None:
1463
+ entry.is_existing_field_removable = update.is_existing_field_removable
1464
+ if update.is_field_addable is not None:
1465
+ entry.is_field_addable = update.is_field_addable
1466
+ elif isinstance(entry, ExperimentPluginEntry) and isinstance(update, ElnPluginEntryUpdateCriteria):
1467
+ if update.plugin_name is not None:
1468
+ entry.plugin_name = update.plugin_name
1469
+ if update.provides_template_data is not None:
1470
+ entry.provides_template_data = update.provides_template_data
1471
+ if update.using_template_data is not None:
1472
+ entry.using_template_data = update.using_template_data
1473
+ if isinstance(entry, ExperimentTableEntry) and isinstance(update, ElnTableEntryUpdateCriteria):
1474
+ if update.data_type_layout_name is not None:
1475
+ entry.data_type_layout_name = update.data_type_layout_name
1476
+ if update.extension_type_list is not None:
1477
+ entry.extension_type_list = update.extension_type_list
1478
+ if update.field_set_id_list is not None:
1479
+ entry.field_set_id_list = update.field_set_id_list
1480
+ if update.is_existing_field_removable is not None:
1481
+ entry.is_existing_field_removable = update.is_existing_field_removable
1482
+ if update.is_field_addable is not None:
1483
+ entry.is_field_addable = update.is_field_addable
1484
+ if update.show_key_fields is not None:
1485
+ entry.show_key_fields = update.show_key_fields
1486
+ if update.table_column_list is not None:
1487
+ entry.table_column_list = update.table_column_list
1488
+ elif isinstance(entry, ExperimentTempDataEntry) and isinstance(update, ElnTempDataEntryUpdateCriteria):
1489
+ if update.plugin_path is not None:
1490
+ entry.plugin_path = update.plugin_path
1491
+ elif isinstance(entry, ExperimentTextEntry) and isinstance(update, ElnTextEntryUpdateCriteria):
1492
+ # Text update criteria has no additional fields.
1493
+ pass
1014
1494
 
1015
1495
  def get_step_option(self, step: Step, option: str) -> str:
1016
1496
  """
@@ -1050,9 +1530,10 @@ class ExperimentHandler:
1050
1530
  :return: The map of options for the input step.
1051
1531
  """
1052
1532
  step = self.__to_eln_step(step)
1053
- if step not in self.__step_options:
1054
- self.__step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user, self.get_all_steps()))
1055
- return self.__step_options[step.get_id()]
1533
+ if step not in self._step_options:
1534
+ self._step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user,
1535
+ self.get_all_steps()))
1536
+ return self._step_options[step.get_id()]
1056
1537
 
1057
1538
  def add_step_options(self, step: Step, mapping: Mapping[str, str]):
1058
1539
  """
@@ -1076,7 +1557,7 @@ class ExperimentHandler:
1076
1557
  """
1077
1558
  options: dict[str, str] = self.get_step_options(step)
1078
1559
  options.update(mapping)
1079
- self.update_step(step, entry_options_map=options)
1560
+ self.force_step_update_params(step, entry_options_map=options)
1080
1561
 
1081
1562
  def initialize_step(self, step: Step) -> None:
1082
1563
  """
@@ -1094,7 +1575,7 @@ class ExperimentHandler:
1094
1575
  # Avoid unnecessary calls if the step is already initialized.
1095
1576
  step: ElnEntryStep = self.__to_eln_step(step)
1096
1577
  if step.eln_entry.template_item_fulfilled_timestamp is None:
1097
- self.update_step(step, template_item_fulfilled_timestamp=round(time.time() * 1000))
1578
+ self.force_step_update_params(step, template_item_fulfilled_timestamp=TimeUtil.now_in_millis())
1098
1579
 
1099
1580
  def uninitialize_step(self, step: Step) -> None:
1100
1581
  """
@@ -1112,7 +1593,7 @@ class ExperimentHandler:
1112
1593
  # Avoid unnecessary calls if the step is already uninitialized.
1113
1594
  step: ElnEntryStep = self.__to_eln_step(step)
1114
1595
  if step.eln_entry.template_item_fulfilled_timestamp is not None:
1115
- self.update_step(step, clear_template_item_fulfilled_timestamp=True)
1596
+ self.force_step_update_params(step, clear_template_item_fulfilled_timestamp=True)
1116
1597
 
1117
1598
  def complete_step(self, step: Step) -> None:
1118
1599
  """
@@ -1128,7 +1609,7 @@ class ExperimentHandler:
1128
1609
  If given a name, throws an exception if no step of the given name exists in the experiment.
1129
1610
  """
1130
1611
  step = self.__to_eln_step(step)
1131
- if step.eln_entry.entry_status not in self.__ENTRY_COMPLETE_STATUSES:
1612
+ if step.eln_entry.entry_status not in self._ENTRY_COMPLETE_STATUSES:
1132
1613
  step.complete_step()
1133
1614
  step.eln_entry.entry_status = ExperimentEntryStatus.Completed
1134
1615
 
@@ -1146,7 +1627,7 @@ class ExperimentHandler:
1146
1627
  If given a name, throws an exception if no step of the given name exists in the experiment.
1147
1628
  """
1148
1629
  step = self.__to_eln_step(step)
1149
- if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
1630
+ if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1150
1631
  step.unlock_step()
1151
1632
  step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired
1152
1633
 
@@ -1168,8 +1649,8 @@ class ExperimentHandler:
1168
1649
  If given a name, throws an exception if no step of the given name exists in the experiment.
1169
1650
  """
1170
1651
  step = self.__to_eln_step(step)
1171
- if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
1172
- self.update_step(step, entry_status=ExperimentEntryStatus.Disabled)
1652
+ if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1653
+ self.force_step_update_params(step, entry_status=ExperimentEntryStatus.Disabled)
1173
1654
 
1174
1655
  def step_is_submitted(self, step: Step) -> bool:
1175
1656
  """
@@ -1184,7 +1665,7 @@ class ExperimentHandler:
1184
1665
  If given a name, throws an exception if no step of the given name exists in the experiment.
1185
1666
  :return: True if the step's status is Completed or CompletedApproved. False otherwise.
1186
1667
  """
1187
- return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_COMPLETE_STATUSES
1668
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_COMPLETE_STATUSES
1188
1669
 
1189
1670
  def step_is_locked(self, step: Step) -> bool:
1190
1671
  """
@@ -1200,7 +1681,171 @@ class ExperimentHandler:
1200
1681
  :return: True if the step's status is Completed, CompletedApproved, Disabled, LockedAwaitingApproval,
1201
1682
  or LockedRejected. False otherwise.
1202
1683
  """
1203
- return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES
1684
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES
1685
+
1686
+ # FR-47464: Some functions that can help with entry placement.
1687
+ def get_all_tabs(self) -> list[ElnExperimentTab]:
1688
+ """
1689
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1690
+ list of tabs in the experiment and caches them.
1691
+
1692
+ :return: A list of all the tabs in the experiment in order of appearance.
1693
+ """
1694
+ if not self._queried_all_tabs:
1695
+ self._tabs = self._eln_man.get_tabs_for_experiment(self._exp_id)
1696
+ self._tabs.sort(key=lambda t: t.tab_order)
1697
+ self._tabs_by_id = {tab.tab_id: tab for tab in self._tabs}
1698
+ self._tabs_by_name = {tab.tab_name: tab for tab in self._tabs}
1699
+ return self._tabs
1700
+
1701
+ def get_first_tab(self) -> ElnExperimentTab:
1702
+ """
1703
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1704
+ list of tabs in the experiment and caches them.
1705
+
1706
+ :return: The first tab in the experiment.
1707
+ """
1708
+ return self.get_all_tabs()[0]
1709
+
1710
+ def get_last_tab(self) -> ElnExperimentTab:
1711
+ """
1712
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1713
+ list of tabs in the experiment and caches them.
1714
+
1715
+ :return: The last tab in the experiment.
1716
+ """
1717
+ return self.get_all_tabs()[-1]
1718
+
1719
+ def create_tab(self, tab_name: str) -> ElnExperimentTab:
1720
+ """
1721
+ Create a new tab in the experiment with the input name.
1722
+
1723
+ :param tab_name: The name of the tab to create.
1724
+ :return: The newly created tab.
1725
+ """
1726
+ crit = ElnExperimentTabAddCriteria(tab_name, [])
1727
+ tab: ElnExperimentTab = self._eln_man.add_tab_for_experiment(self._exp_id, crit)
1728
+ self.add_tab_to_cache(tab)
1729
+ return tab
1730
+
1731
+ def get_tab(self, tab_name: str | int, exception_on_none: bool = True) -> ElnExperimentTab:
1732
+ """
1733
+ Return the tab with the input name.
1734
+
1735
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1736
+ list of tabs in the experiment and caches them.
1737
+
1738
+ :param tab_name: The name or ID of the tab to get.
1739
+ :param exception_on_none: If True, raises an exception if no tab with the given name exists.
1740
+ :return: The tab with the input name, or None if no such tab exists.
1741
+ """
1742
+ if tab_name not in self._tabs_by_name and tab_name not in self._tabs_by_id:
1743
+ self.get_all_tabs()
1744
+ if isinstance(tab_name, str):
1745
+ tab = self._tabs_by_name.get(tab_name)
1746
+ else:
1747
+ tab = self._tabs_by_id.get(tab_name)
1748
+ if tab is None and exception_on_none:
1749
+ raise SapioException(f"No tab with the name\\ID \"{tab_name}\" exists in this experiment.")
1750
+ return tab
1751
+
1752
+ def get_steps_in_tab(self, tab: TabIdentifier | str, data_type: DataTypeIdentifier | None = None) \
1753
+ -> list[ElnEntryStep]:
1754
+ """
1755
+ Get all the steps in the input tab sorted in order of appearance.
1756
+
1757
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1758
+ list of tabs in the experiment and caches them.
1759
+
1760
+ If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1761
+ and caches them.
1762
+
1763
+ :param tab: The tab to get the steps of. This can be either an ElnExperimentTab object, or the name or ID of
1764
+ the tab.
1765
+ :param data_type: The data type to filter the steps by. If None, all steps are returned.
1766
+ :return: A list of all the steps in the input tab sorted in order of appearance.
1767
+ """
1768
+ tab: ElnExperimentTab = self.__to_eln_tab(tab)
1769
+ steps: list[ElnEntryStep] = []
1770
+ for step in self.get_all_steps(data_type):
1771
+ if step.eln_entry.notebook_experiment_tab_id == tab.tab_id:
1772
+ steps.append(step)
1773
+ return steps
1774
+
1775
+ def get_next_entry_order_in_tab(self, tab: TabIdentifier | str) -> int:
1776
+ """
1777
+ Get the next available order for a new entry in the input tab.
1778
+
1779
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1780
+ list of tabs in the experiment and caches them.
1781
+
1782
+ If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1783
+ and caches them.
1784
+
1785
+ :param tab: The tab to get the next entry order of. This can be either an ElnExperimentTab object, or the name
1786
+ or ID of the tab.
1787
+ :return: The next available order for a new entry in the input tab.
1788
+ """
1789
+ steps = self.get_steps_in_tab(tab)
1790
+ return steps[-1].eln_entry.order + 1 if steps else 0
1791
+
1792
+ # FR-47530: Add functions for dealing with entry positioning.
1793
+ def step_to_position(self, step: Step) -> ElnEntryPosition:
1794
+ """
1795
+ Get the position of the input step in the experiment.
1796
+
1797
+ If no step functions have been called before and a step is being searched for by name, queries for the
1798
+ list of steps in the experiment and caches them.
1799
+
1800
+ :param step:
1801
+ The step to get the position of.
1802
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1803
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1804
+ :return: The position of the input step in the experiment.
1805
+ """
1806
+ step: ElnEntryStep = self.__to_eln_step(step)
1807
+ entry: ExperimentEntry = step.eln_entry
1808
+ return ElnEntryPosition(entry.notebook_experiment_tab_id,
1809
+ entry.order,
1810
+ entry.column_span,
1811
+ entry.column_order)
1812
+
1813
+ def step_at_position(self, position: ElnEntryPosition) -> Step | None:
1814
+ """
1815
+ Get the step at the input position in the experiment.
1816
+
1817
+ If no step functions have been called before and a step is being searched for by name, queries for the
1818
+ list of steps in the experiment and caches them.
1819
+
1820
+ :param position: The position to get the step at.
1821
+ :return: The step at the input position in the experiment, or None if no step exists at that position.
1822
+ """
1823
+ if position.tab_id is None or position.order is None:
1824
+ raise SapioException("The provided position must at least have a tab ID and order.")
1825
+ for step in self.get_steps_in_tab(position.tab_id):
1826
+ entry: ExperimentEntry = step.eln_entry
1827
+ if entry.order != position.order:
1828
+ continue
1829
+ if position.column_span is not None and entry.column_span != position.column_span:
1830
+ continue
1831
+ if position.column_order is not None and entry.column_order != position.column_order:
1832
+ continue
1833
+ return step
1834
+ return None
1835
+
1836
+ # FR-47530: Create a function for adding protocol templates to the experiment.
1837
+ def add_protocol(self, protocol: ProtocolTemplateInfo | int, position: ElnEntryPosition) -> list[ElnEntryStep]:
1838
+ """
1839
+ Add a protocol to the experiment. Updates the handler cache with the newly created entries.
1840
+
1841
+ :param protocol: The protocol to add. This can be either a ProtocolTemplateInfo object or the ID of the
1842
+ protocol template.
1843
+ :param position: The position that the protocol's first entry will be placed at.
1844
+ :return: The newly created protocol entries.
1845
+ """
1846
+ protocol = protocol if isinstance(protocol, int) else protocol.template_id
1847
+ new_entries: list[ExperimentEntry] = self._eln_man.add_protocol_template(self._exp_id, protocol, position)
1848
+ return self.add_entries_to_caches(new_entries)
1204
1849
 
1205
1850
  def __to_eln_step(self, step: Step) -> ElnEntryStep:
1206
1851
  """
@@ -1210,15 +1855,24 @@ class ExperimentHandler:
1210
1855
 
1211
1856
  :return: The input step as an ElnEntryStep.
1212
1857
  """
1213
- return self.get_step(step) if isinstance(step, str) else step
1858
+ if isinstance(step, str):
1859
+ return self.get_step(step)
1860
+ if isinstance(step, int):
1861
+ return self._steps_by_id.get(step)
1862
+ if isinstance(step, ExperimentEntry):
1863
+ return self.add_entry_to_caches(step)
1864
+ return step
1214
1865
 
1215
- def __get_experiment_options(self) -> dict[str, str]:
1866
+ def __to_eln_tab(self, tab: TabIdentifier | str) -> ElnExperimentTab:
1216
1867
  """
1217
- Cache the experiment options if they haven't been cached yet.
1868
+ Convert a variable that could be either a string or an ElnExperimentTab to just an ElnExperimentTab.
1869
+ This will query and cache the tabs for the experiment if the input tab is a name and the tabs have not been
1870
+ cached before.
1218
1871
 
1219
- :return: The options for this experiment.
1872
+ :return: The input tab as an ElnExperimentTab.
1220
1873
  """
1221
- if hasattr(self, "_ExperimentHandler__exp_options"):
1222
- return self.__exp_options
1223
- self.__exp_options = self.__eln_man.get_notebook_experiment_options(self.__exp_id)
1224
- return self.__exp_options
1874
+ if isinstance(tab, str):
1875
+ return self.get_tab(tab)
1876
+ if isinstance(tab, int):
1877
+ return [x for x in self._tabs if x.tab_id == tab][0]
1878
+ return tab