sapiopycommons 2025.3.21a458__py3-none-any.whl → 2025.3.26a460__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.

@@ -1,90 +1,133 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
3
+ import warnings
4
4
  from collections.abc import Mapping, Iterable
5
5
  from typing import TypeAlias
6
6
  from weakref import WeakValueDictionary
7
7
 
8
8
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
9
+ from sapiopylib.rest.DataRecordManagerService import DataRecordManager
9
10
  from sapiopylib.rest.ELNService import ElnManager
10
11
  from sapiopylib.rest.User import SapioUser
11
12
  from sapiopylib.rest.pojo.DataRecord import DataRecord
13
+ from sapiopylib.rest.pojo.TableColumn import TableColumn
14
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition
15
+ from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
12
16
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
13
17
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
14
- from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
15
- from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria
18
+ from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry, ExperimentTableEntry, ExperimentFormEntry, \
19
+ ExperimentAttachmentEntry, ExperimentPluginEntry, ExperimentDashboardEntry, ExperimentTextEntry, \
20
+ ExperimentTempDataEntry, EntryAttachment, EntryRecordAttachment
21
+ from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria, \
22
+ ElnTableEntryUpdateCriteria, ElnFormEntryUpdateCriteria, ElnAttachmentEntryUpdateCriteria, \
23
+ ElnPluginEntryUpdateCriteria, ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria, \
24
+ ElnTempDataEntryUpdateCriteria, ElnEntryCriteria
16
25
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnExperimentStatus, ElnEntryType, \
17
26
  ElnBaseDataType
27
+ from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab, ElnExperimentTabAddCriteria
28
+ from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
18
29
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
19
30
  from sapiopylib.rest.pojo.webhook.WebhookDirective import ElnExperimentDirective
20
31
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
21
32
  from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
33
+ from sapiopylib.rest.utils.plates.MultiLayerPlating import MultiLayerPlateConfig, MultiLayerPlateLayer, \
34
+ MultiLayerDataTypeConfig, MultiLayerReplicateConfig, MultiLayerDilutionConfig
35
+ from sapiopylib.rest.utils.plates.MultiLayerPlatingUtils import MultiLayerPlatingManager
36
+ from sapiopylib.rest.utils.plates.PlatingUtils import PlatingOrder
22
37
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
23
38
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInstanceManager, RecordModelManager
24
39
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
25
40
  from sapiopylib.rest.utils.recordmodel.properties import Child
26
41
 
42
+ from sapiopycommons.datatype.data_fields import SystemFields
27
43
  from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
44
+ from sapiopycommons.eln.experiment_tags import PLATE_DESIGNER_PLUGIN
28
45
  from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
29
- DataTypeIdentifier, RecordModel
46
+ DataTypeIdentifier, RecordModel, FieldMap, FieldIdentifier
30
47
  from sapiopycommons.general.exceptions import SapioException
48
+ from sapiopycommons.general.time_util import TimeUtil
49
+ from sapiopycommons.recordmodel.record_handler import RecordHandler
31
50
 
32
51
  Step: TypeAlias = str | ElnEntryStep
33
52
  """An object representing an identifier to an ElnEntryStep. May be either the name of the step or the ElnEntryStep
34
53
  itself."""
54
+ Tab: TypeAlias = str | ElnExperimentTab
55
+ """An object representing an identifier to an ElnExperimentTab. May be either the name of the tab or the
56
+ ElnExperimentTab itself."""
57
+ ElnDataTypeFields: TypeAlias = AbstractVeloxFieldDefinition | ElnFieldSetInfo | str | int
58
+ """An object representing an identifier to an ElnDataType field. These can be field definitions for ad hoc fields,
59
+ predefined field set info objects, predefined field names, or integers for predefined field set IDs."""
35
60
 
36
61
 
37
62
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
38
63
  class ExperimentHandler:
39
64
  user: SapioUser
40
65
  context: SapioWebhookContext | None
41
- """The context that this handler is working from."""
66
+ """The context that this handler is working from, if any."""
42
67
 
68
+ # CR-47485: Made variables protected instead of private.
43
69
  # Basic experiment info from the context.
44
- __eln_exp: ElnExperiment
70
+ _eln_exp: ElnExperiment
45
71
  """The ELN experiment from the context."""
46
- __protocol: ElnExperimentProtocol
72
+ _protocol: ElnExperimentProtocol
47
73
  """The ELN experiment as a protocol."""
48
- __exp_id: int
74
+ _exp_id: int
49
75
  """The ID of this experiment's notebook. Used for making update webservice calls."""
50
76
 
51
77
  # Managers.
52
- __eln_man: ElnManager
78
+ _eln_man: ElnManager
53
79
  """The ELN manager. Used for updating the experiment and its steps."""
54
- __inst_man: RecordModelInstanceManager
80
+ _inst_man: RecordModelInstanceManager
55
81
  """The record model instance manager. Used for wrapping the data records of a step as record models."""
82
+ _rec_handler: RecordHandler
83
+ """The record handler. Also used for wrapping the data records of a step as record models."""
56
84
 
57
85
  # Only a fraction of the information about the current experiment exists in the context. Much information requires
58
86
  # additional queries to obtain, but may also be repeatedly accessed. In such cases, cache the information after it
59
87
  # has been requested so that the user doesn't need to worry about caching it themselves.
60
88
  # CR-46341: Replace class variables with instance variables.
61
- __exp_record: DataRecord | None
89
+ _exp_record: DataRecord | None
62
90
  """The data record for this experiment. Only cached when first accessed."""
63
- __exp_template: ElnTemplate | None
91
+ _exp_template: ElnTemplate | None
64
92
  """The template for this experiment. Only cached when first accessed."""
65
- __exp_options: dict[str, str]
93
+ _exp_options: dict[str, str]
66
94
  """Experiment options for this experiment. Only cached when first accessed."""
67
95
 
68
- __queried_all_steps: bool
96
+ _queried_all_steps: bool
69
97
  """Whether this ExperimentHandler has queried the system for all steps in the experiment."""
70
- __steps: dict[str, ElnEntryStep]
71
- """Steps from this experiment. All steps are cached the first time any individual step is accessed."""
72
- __step_options: dict[int, dict[str, str]]
98
+ _steps: dict[str, ElnEntryStep]
99
+ """Steps from this experiment by their name. All steps are cached the first time any individual step is accessed."""
100
+ _steps_by_id: dict[int, ElnEntryStep]
101
+ """Steps from this experiment by their ID. All steps are cached the first time any individual step is accessed."""
102
+ _step_options: dict[int, dict[str, str]]
73
103
  """Entry options for each step in this experiment. All entry options are cached the first time any individual step's
74
104
  options are queried. The cache is updated whenever the entry options for a step are changed by this handler."""
75
105
 
106
+ _step_updates: dict[int, AbstractElnEntryUpdateCriteria]
107
+ """A dictionary of entry updates that have been made by this handler. Used to batch update entries."""
108
+
109
+ _queried_all_tabs: bool
110
+ """Whether this ExperimentHandler has queried the system for all tabs in the experiment."""
111
+ _tabs: list[ElnExperimentTab]
112
+ """The tabs for this experiment. Only cached when first accessed."""
113
+ _tabs_by_name: dict[str, ElnExperimentTab]
114
+ """The tabs for this experiment by their name. Only cached when first accessed."""
115
+
116
+ _predefined_fields: dict[str, dict[str, AbstractVeloxFieldDefinition]]
117
+ """A dictionary of predefined fields for each ELN data type. Only cached when first accessed."""
118
+
76
119
  # Constants
77
- __ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
120
+ _ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
78
121
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
79
- __ENTRY_LOCKED_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved,
80
- ExperimentEntryStatus.Disabled, ExperimentEntryStatus.LockedAwaitingApproval,
81
- ExperimentEntryStatus.LockedRejected]
122
+ _ENTRY_LOCKED_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved,
123
+ ExperimentEntryStatus.Disabled, ExperimentEntryStatus.LockedAwaitingApproval,
124
+ ExperimentEntryStatus.LockedRejected]
82
125
  """The set of statuses that an ELN entry could have and be considered locked."""
83
- __EXPERIMENT_COMPLETE_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved]
126
+ _EXPERIMENT_COMPLETE_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved]
84
127
  """The set of statuses that an ELN experiment could have and be considered completed."""
85
- __EXPERIMENT_LOCKED_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved,
86
- ElnExperimentStatus.LockedRejected, ElnExperimentStatus.LockedAwaitingApproval,
87
- ElnExperimentStatus.Canceled]
128
+ _EXPERIMENT_LOCKED_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved,
129
+ ElnExperimentStatus.LockedRejected, ElnExperimentStatus.LockedAwaitingApproval,
130
+ ElnExperimentStatus.Canceled]
88
131
  """The set of statuses that an ELN experiment could have and be considered locked."""
89
132
 
90
133
  __instances: WeakValueDictionary[str, ExperimentHandler] = WeakValueDictionary()
@@ -124,27 +167,40 @@ class ExperimentHandler:
124
167
  experiment = param_results[2]
125
168
 
126
169
  # Get the basic information about this experiment that already exists in the context and is often used.
127
- self.__eln_exp = experiment
128
- self.__protocol = ElnExperimentProtocol(experiment, self.user)
129
- self.__exp_id = self.__protocol.get_id()
170
+ self._eln_exp = experiment
171
+ self._protocol = ElnExperimentProtocol(experiment, self.user)
172
+ self._exp_id = self._protocol.get_id()
130
173
 
131
174
  # Grab various managers that may be used.
132
- self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
133
- self.__inst_man = RecordModelManager(self.user).instance_manager
175
+ self._eln_man = DataMgmtServer.get_eln_manager(self.user)
176
+ self._inst_man = RecordModelManager(self.user).instance_manager
177
+ self._rec_handler = RecordHandler(self.user)
134
178
 
135
179
  # Create empty caches to fill when necessary.
136
- self.__steps = {}
137
- self.__step_options = {}
180
+ self._queried_all_steps = False
181
+ self._steps = {}
182
+ self._steps_by_id = {}
183
+ self._step_options = {}
184
+ self._step_updates = {}
185
+
186
+ self._tabs = []
187
+ self._tabs_by_name = {}
188
+
189
+ self._queried_all_tabs = False
190
+
138
191
  # CR-46330: Cache any experiment entry information that might already exist in the context.
139
- self.__queried_all_steps = False
140
192
  # We can only trust the entries in the context if the experiment that this handler is for is the same as the
141
193
  # one from the context.
142
194
  if self.context is not None and self.context.eln_experiment == experiment:
195
+ cache_steps: list[ElnEntryStep] = []
143
196
  if self.context.experiment_entry is not None:
144
- self.__steps.update({self.context.active_step.get_name(): self.context.active_step})
197
+ cache_steps.append(ElnEntryStep(self._protocol, self.context.experiment_entry))
145
198
  if self.context.experiment_entry_list is not None:
146
199
  for entry in self.context.experiment_entry_list:
147
- self.__steps.update({entry.entry_name: ElnEntryStep(self.__protocol, entry)})
200
+ cache_steps.append(ElnEntryStep(self._protocol, entry))
201
+ for step in cache_steps:
202
+ self._steps.update({step.get_name(): step})
203
+ self._steps_by_id.update({step.get_id(): step})
148
204
 
149
205
  @staticmethod
150
206
  def __parse_params(context: UserIdentifier, experiment: ExperimentIdentifier | SapioRecord | None = None) \
@@ -181,10 +237,90 @@ class ExperimentHandler:
181
237
  if not experiment:
182
238
  raise SapioException(f"No experiment with record ID {record_id} located in the system.")
183
239
  if experiment is None:
184
- raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided parameters.")
240
+ raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided "
241
+ "parameters.")
185
242
 
186
243
  return user, context, experiment
187
244
 
245
+ @property
246
+ def protocol(self) -> ElnExperimentProtocol:
247
+ """
248
+ The ELN experiment that this handler is for as a protocol object.
249
+ """
250
+ return self._protocol
251
+
252
+ # CR-47485: Add methods for clearing and updating the caches of this ExperimentHandler.
253
+ def clear_all_caches(self) -> None:
254
+ """
255
+ Clear all caches that this ExperimentHandler uses.
256
+ """
257
+ self.clear_step_caches()
258
+ self.clear_experiment_caches()
259
+ self.clear_tab_caches()
260
+
261
+ def clear_step_caches(self) -> None:
262
+ """
263
+ Clear the step caches that this ExperimentHandler uses.
264
+ """
265
+ self._queried_all_steps = False
266
+ self._steps.clear()
267
+ self._steps_by_id.clear()
268
+ self._step_options.clear()
269
+ self._step_updates.clear()
270
+
271
+ def clear_experiment_caches(self) -> None:
272
+ """
273
+ Clear the experiment information caches that this ExperimentHandler uses.
274
+ """
275
+ self._exp_record = None
276
+ self._exp_template = None
277
+ self._exp_options = {}
278
+
279
+ def clear_tab_caches(self) -> None:
280
+ """
281
+ Clear the tab caches that this ExperimentHandler uses.
282
+ """
283
+ self._queried_all_tabs = False
284
+ self._tabs.clear()
285
+ self._tabs_by_name.clear()
286
+
287
+ def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> None:
288
+ """
289
+ Add the given entry to the cache of steps for this experiment. This is necessary in order for certain methods to
290
+ work. You should only need to do this if you have created a new entry in your code using a method outside
291
+ of this ExperimentHandler.
292
+
293
+ :param entry: The entry to add to the cache.
294
+ """
295
+ if isinstance(entry, ExperimentEntry):
296
+ entry = ElnEntryStep(self._protocol, entry)
297
+ self._steps.update({entry.get_name(): entry})
298
+ self._steps_by_id.update({entry.get_id(): entry})
299
+ # Skipping the options cache. The get_step_options method will update the cache when necessary.
300
+
301
+ def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> None:
302
+ """
303
+ Add the given entries to the cache of steps for this experiment. This is necessary in order for certain methods
304
+ to work. You should only need to do this if you have created a new entry in your code using a method outside
305
+ of this ExperimentHandler.
306
+
307
+ :param entries: The entries to add to the cache.
308
+ """
309
+ for entry in entries:
310
+ self.add_entry_to_caches(entry)
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_name[tab.tab_name] = tab
323
+
188
324
  # FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
189
325
  @staticmethod
190
326
  def create_experiment(context: SapioWebhookContext,
@@ -240,7 +376,8 @@ class ExperimentHandler:
240
376
  template_name: str,
241
377
  experiment_name: str | None = None,
242
378
  parent_record: SapioRecord | None = None, *,
243
- template_version: int | None = None, active_templates_only: bool = True) -> SapioWebhookResult:
379
+ template_version: int | None = None,
380
+ active_templates_only: bool = True) -> SapioWebhookResult:
244
381
  """
245
382
  Create a SapioWebhookResult that, when returned by a webhook handler, sends the user to a new experiment of the
246
383
  input template name.
@@ -276,24 +413,24 @@ class ExperimentHandler:
276
413
  when the experiment template doesn't exist.
277
414
  :return: This experiment's template. None if it has no template.
278
415
  """
279
- template_id: int | None = self.__eln_exp.template_id
416
+ template_id: int | None = self._eln_exp.template_id
280
417
  if template_id is None:
281
- self.__exp_template = None
418
+ self._exp_template = None
282
419
  if exception_on_none:
283
- raise SapioException(f"Experiment with ID {self.__exp_id} has no template ID.")
420
+ raise SapioException(f"Experiment with ID {self._exp_id} has no template ID.")
284
421
  return None
285
422
 
286
- if not hasattr(self, "_ExperimentHandler__exp_template"):
423
+ if not hasattr(self, "_exp_template"):
287
424
  # PR-46504: Allow inactive and non-latest version templates to be queried.
288
425
  query = TemplateExperimentQueryPojo(template_id_white_list=[template_id],
289
426
  active_templates_only=False,
290
427
  latest_version_only=False)
291
- templates: list[ElnTemplate] = self.__eln_man.get_template_experiment_list(query)
428
+ templates: list[ElnTemplate] = self._eln_man.get_template_experiment_list(query)
292
429
  # PR-46504: Set the exp_template to None if there are no results.
293
- self.__exp_template = templates[0] if templates else None
294
- if self.__exp_template is None and exception_on_none:
295
- raise SapioException(f"Experiment template not found for experiment with ID {self.__exp_id}.")
296
- return self.__exp_template
430
+ self._exp_template = templates[0] if templates else None
431
+ if self._exp_template is None and exception_on_none:
432
+ raise SapioException(f"Experiment template not found for experiment with ID {self._exp_id}.")
433
+ return self._exp_template
297
434
 
298
435
  # CR-46104: Change get_template_name to behave like NotebookProtocolImpl.getTemplateName (i.e. first see if the
299
436
  # experiment template exists, and if not, see if the experiment record exists, instead of only checking the
@@ -310,18 +447,18 @@ class ExperimentHandler:
310
447
  when the template name doesn't exist.
311
448
  :return: The template name of the current experiment. None if it has no template name.
312
449
  """
313
- if not hasattr(self, "_ExperimentHandler__exp_template"):
450
+ if not hasattr(self, "_exp_template"):
314
451
  self.get_experiment_template(False)
315
- if self.__exp_template is None and not hasattr(self, "_ExperimentHandler__exp_record"):
452
+ if self._exp_template is None and not hasattr(self, "_exp_record"):
316
453
  self.get_experiment_record(False)
317
454
 
318
455
  name: str | None = None
319
- if self.__exp_template is not None:
320
- name = self.__exp_template.template_name
321
- elif self.__exp_record is not None:
322
- name = self.__exp_record.get_field_value("TemplateExperimentName")
456
+ if self._exp_template is not None:
457
+ name = self._exp_template.template_name
458
+ elif self._exp_record is not None:
459
+ name = self._exp_record.get_field_value("TemplateExperimentName")
323
460
  if name is None and exception_on_none:
324
- raise SapioException(f"Template name not found for experiment with ID {self.__exp_id}.")
461
+ raise SapioException(f"Template name not found for experiment with ID {self._exp_id}.")
325
462
  return name
326
463
 
327
464
  def get_experiment_record(self, exception_on_none: bool = True) -> DataRecord | None:
@@ -332,21 +469,23 @@ class ExperimentHandler:
332
469
  when the experiment record doesn't exist.
333
470
  :return: The data record for this experiment. None if it has no record.
334
471
  """
335
- if not hasattr(self, "_ExperimentHandler__exp_record"):
336
- self.__exp_record = self.__protocol.get_record()
337
- if self.__exp_record is None and exception_on_none:
338
- raise SapioException(f"Experiment record not found for experiment with ID {self.__exp_id}.")
339
- return self.__exp_record
472
+ if not hasattr(self, "_exp_record"):
473
+ self._exp_record = self._protocol.get_record()
474
+ if self._exp_record is None and exception_on_none:
475
+ raise SapioException(f"Experiment record not found for experiment with ID {self._exp_id}.")
476
+ return self._exp_record
340
477
 
341
- def get_experiment_model(self, wrapper_type: type[WrappedType]) -> WrappedType:
478
+ # CR-47491: Support not providing a wrapper type to receive PyRecordModels instead of WrappedRecordModels.
479
+ def get_experiment_model(self, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
342
480
  """
343
481
  Query for the data record of this experiment and wrap it as a record model with the given wrapper.
344
482
  The returned record is cached by the ExperimentHandler.
345
483
 
346
- :param wrapper_type: The record model wrapper to use.
484
+ :param wrapper_type: The record model wrapper to use. If not provided, the record is returned as a
485
+ PyRecordModel instead of a WrappedRecordModel.
347
486
  :return: The record model for this experiment.
348
487
  """
349
- return self.__inst_man.add_existing_record_of_type(self.get_experiment_record(), wrapper_type)
488
+ return self._rec_handler.wrap_model(self.get_experiment_record(), wrapper_type)
350
489
 
351
490
  def update_experiment(self,
352
491
  experiment_name: str | None = None,
@@ -368,14 +507,14 @@ class ExperimentHandler:
368
507
  criteria.new_experiment_name = experiment_name
369
508
  criteria.new_experiment_status = experiment_status
370
509
  criteria.experiment_option_map = experiment_option_map
371
- self.__eln_man.update_notebook_experiment(self.__exp_id, criteria)
510
+ self._eln_man.update_notebook_experiment(self._exp_id, criteria)
372
511
 
373
512
  if experiment_name is not None:
374
- self.__eln_exp.notebook_experiment_name = experiment_name
513
+ self._eln_exp.notebook_experiment_name = experiment_name
375
514
  if experiment_status is not None:
376
- self.__eln_exp.notebook_experiment_status = experiment_status
515
+ self._eln_exp.notebook_experiment_status = experiment_status
377
516
  if experiment_option_map is not None:
378
- self.__exp_options = experiment_option_map
517
+ self._exp_options = experiment_option_map
379
518
 
380
519
  def get_experiment_option(self, option: str) -> str:
381
520
  """
@@ -400,7 +539,10 @@ class ExperimentHandler:
400
539
 
401
540
  :return: The map of options for this experiment.
402
541
  """
403
- return self.__get_experiment_options()
542
+ if hasattr(self, "_exp_options"):
543
+ return self._exp_options
544
+ self._exp_options = self._eln_man.get_notebook_experiment_options(self._exp_id)
545
+ return self._exp_options
404
546
 
405
547
  def add_experiment_options(self, mapping: Mapping[str, str]) -> None:
406
548
  """
@@ -425,7 +567,7 @@ class ExperimentHandler:
425
567
 
426
568
  :return: True if the experiment's status is Completed or CompletedApproved. False otherwise.
427
569
  """
428
- return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_COMPLETE_STATUSES
570
+ return self._eln_exp.notebook_experiment_status in self._EXPERIMENT_COMPLETE_STATUSES
429
571
 
430
572
  def experiment_is_canceled(self) -> bool:
431
573
  """
@@ -433,7 +575,7 @@ class ExperimentHandler:
433
575
 
434
576
  :return: True if the experiment's status is Canceled. False otherwise.
435
577
  """
436
- return self.__eln_exp.notebook_experiment_status == ElnExperimentStatus.Canceled
578
+ return self._eln_exp.notebook_experiment_status == ElnExperimentStatus.Canceled
437
579
 
438
580
  def experiment_is_locked(self) -> bool:
439
581
  """
@@ -442,7 +584,7 @@ class ExperimentHandler:
442
584
  :return: True if the experiment's status is Completed, CompletedApproved, Canceled, LockedAwaitingApproval,
443
585
  or LockedRejected. False otherwise.
444
586
  """
445
- return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_LOCKED_STATUSES
587
+ return self._eln_exp.notebook_experiment_status in self._EXPERIMENT_LOCKED_STATUSES
446
588
 
447
589
  def complete_experiment(self) -> None:
448
590
  """
@@ -450,8 +592,8 @@ class ExperimentHandler:
450
592
  experiment is already completed, and does nothing if so.
451
593
  """
452
594
  if not self.experiment_is_complete():
453
- self.__protocol.complete_protocol()
454
- self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Completed
595
+ self._protocol.complete_protocol()
596
+ self._eln_exp.notebook_experiment_status = ElnExperimentStatus.Completed
455
597
 
456
598
  def cancel_experiment(self) -> None:
457
599
  """
@@ -463,8 +605,8 @@ class ExperimentHandler:
463
605
  as those changes are tied to the button instead of being on the experiment status change.
464
606
  """
465
607
  if not self.experiment_is_canceled():
466
- self.__protocol.cancel_protocol()
467
- self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Canceled
608
+ self._protocol.cancel_protocol()
609
+ self._eln_exp.notebook_experiment_status = ElnExperimentStatus.Canceled
468
610
 
469
611
  def step_exists(self, step_name: str) -> bool:
470
612
  """
@@ -523,13 +665,13 @@ class ExperimentHandler:
523
665
  # If we haven't queried the system for all steps in the experiment yet, then the reason that a step is
524
666
  # missing may be because it wasn't in the webhook context. Therefore, query all steps and then check
525
667
  # if the step name is still missing from the experiment before potentially throwing an exception.
526
- if self.__queried_all_steps is False and name not in self.__steps:
527
- self.__queried_all_steps = True
528
- self.__steps.update({step.get_name(): step for step in self.__protocol.get_sorted_step_list()})
668
+ if self._queried_all_steps is False and name not in self._steps:
669
+ self._queried_all_steps = True
670
+ self._steps.update({step.get_name(): step for step in self._protocol.get_sorted_step_list()})
529
671
 
530
- step: ElnEntryStep = self.__steps.get(name)
672
+ step: ElnEntryStep = self._steps.get(name)
531
673
  if step is None and exception_on_none is True:
532
- raise SapioException(f"ElnEntryStep of name \"{name}\" not found in experiment with ID {self.__exp_id}.")
674
+ raise SapioException(f"ElnEntryStep of name \"{name}\" not found in experiment with ID {self._exp_id}.")
533
675
  ret_list.append(step)
534
676
  return ret_list
535
677
 
@@ -543,10 +685,10 @@ class ExperimentHandler:
543
685
  a data type name or wrapper is given, only returns entries that match that data type name or wrapper.
544
686
  :return: Every entry in the experiment in order of appearance that match the provided data type, if any.
545
687
  """
546
- if self.__queried_all_steps is False:
547
- self.__queried_all_steps = True
548
- self.__steps.update({step.get_name(): step for step in self.__protocol.get_sorted_step_list()})
549
- all_steps: list[ElnEntryStep] = self.__protocol.get_sorted_step_list()
688
+ if self._queried_all_steps is False:
689
+ self._queried_all_steps = True
690
+ self._steps.update({step.get_name(): step for step in self._protocol.get_sorted_step_list()})
691
+ all_steps: list[ElnEntryStep] = self._protocol.get_sorted_step_list()
550
692
  if data_type is None:
551
693
  return all_steps
552
694
  data_type: str = AliasUtil.to_data_type_name(data_type)
@@ -600,7 +742,8 @@ class ExperimentHandler:
600
742
  """
601
743
  return self.__to_eln_step(step).get_records()
602
744
 
603
- def get_step_models(self, step: Step, wrapper_type: type[WrappedType]) -> list[WrappedType]:
745
+ def get_step_models(self, step: Step, wrapper_type: type[WrappedType] | None = None) \
746
+ -> list[WrappedType] | list[PyRecordModel]:
604
747
  """
605
748
  Query for the data records for the given step and wrap them as record models with the given type. The returned
606
749
  records are not cached by the ExperimentHandler.
@@ -612,10 +755,11 @@ class ExperimentHandler:
612
755
  The step to get the data records for.
613
756
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
614
757
  If given a name, throws an exception if no step of the given name exists in the experiment.
615
- :param wrapper_type: The record model wrapper to use.
758
+ :param wrapper_type: The record model wrapper to use. If not provided, the records are returned as
759
+ PyRecordModels instead of WrappedRecordModels.
616
760
  :return: The record models for the given step.
617
761
  """
618
- return self.__inst_man.add_existing_records_of_type(self.get_step_records(step), wrapper_type)
762
+ return self._rec_handler.wrap_models(self.get_step_records(step), wrapper_type)
619
763
 
620
764
  def add_step_records(self, step: Step, records: Iterable[SapioRecord]) -> None:
621
765
  """
@@ -719,10 +863,13 @@ class ExperimentHandler:
719
863
  The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
720
864
  """
721
865
  self.set_step_records(step, [record])
866
+ step = self.__to_eln_step(step)
867
+ if isinstance(step.eln_entry, ExperimentFormEntry):
868
+ step.eln_entry.record_id = AliasUtil.to_data_record(record).record_id
722
869
 
723
870
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
724
871
  def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
725
- -> list[PyRecordModel | WrappedType]:
872
+ -> list[WrappedType] | list[PyRecordModel]:
726
873
  """
727
874
  Add rows to an ELNExperimentDetail or ELNSampleDetail table entry. The rows will not appear in the system
728
875
  until a record manager store and commit has occurred.
@@ -744,12 +891,12 @@ class ExperimentHandler:
744
891
  dt: str = step.get_data_type_names()[0]
745
892
  if not ElnBaseDataType.is_eln_type(dt):
746
893
  raise SapioException("The provided step is not an ELN data type entry.")
747
- records: list[PyRecordModel] = self.__inst_man.add_new_records(dt, count)
894
+ records: list[PyRecordModel] = self._inst_man.add_new_records(dt, count)
748
895
  if wrapper_type:
749
- return self.__inst_man.wrap_list(records, wrapper_type)
896
+ return self._inst_man.wrap_list(records, wrapper_type)
750
897
  return records
751
898
 
752
- def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> PyRecordModel | WrappedType:
899
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
753
900
  """
754
901
  Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
755
902
  until a record manager store and commit has occurred.
@@ -803,7 +950,7 @@ class ExperimentHandler:
803
950
  else:
804
951
  record.delete()
805
952
  if data_records:
806
- record_models: list[PyRecordModel] = self.__inst_man.add_existing_records(data_records)
953
+ record_models: list[PyRecordModel] = self._inst_man.add_existing_records(data_records)
807
954
  for record in record_models:
808
955
  record.delete()
809
956
 
@@ -827,8 +974,9 @@ class ExperimentHandler:
827
974
  """
828
975
  self.remove_eln_rows(step, [record])
829
976
 
830
- def add_sample_details(self, step: Step, samples: list[RecordModel], wrapper_type: type[WrappedType]) \
831
- -> list[PyRecordModel | WrappedType]:
977
+ def add_sample_details(self, step: Step, samples: list[RecordModel],
978
+ wrapper_type: type[WrappedType] | None = None) \
979
+ -> list[WrappedType] | list[PyRecordModel]:
832
980
  """
833
981
  Add sample details to a sample details entry while relating them to the input sample records.
834
982
 
@@ -858,9 +1006,10 @@ class ExperimentHandler:
858
1006
  })
859
1007
  records.append(detail)
860
1008
  if wrapper_type:
861
- return self.__inst_man.wrap_list(records, wrapper_type)
1009
+ return self._inst_man.wrap_list(records, wrapper_type)
862
1010
  return records
863
1011
 
1012
+ # noinspection PyPep8Naming
864
1013
  def update_step(self, step: Step,
865
1014
  entry_name: str | None = None,
866
1015
  related_entry_set: Iterable[int] | None = None,
@@ -927,8 +1076,227 @@ class ExperimentHandler:
927
1076
  If you wish to add options to the existing map of options that an entry has, use the
928
1077
  add_step_options method.
929
1078
  """
1079
+ # FR-47468: Deprecating this since the parameters are ordered. The new method requires keyword parameters, so
1080
+ # that we can add new parameters wherever we want without breaking existing code.
1081
+ warnings.warn("Update step is deprecated. Use force_entry_update_params instead.",
1082
+ DeprecationWarning)
1083
+ self.force_step_update_params(step,
1084
+ entry_name=entry_name,
1085
+ related_entry_set=related_entry_set,
1086
+ dependency_set=dependency_set,
1087
+ entry_status=entry_status,
1088
+ order=order,
1089
+ description=description,
1090
+ requires_grabber_plugin=requires_grabber_plugin,
1091
+ is_initialization_required=is_initialization_required,
1092
+ notebook_experiment_tab_id=notebook_experiment_tab_id,
1093
+ entry_height=entry_height,
1094
+ column_order=column_order,
1095
+ column_span=column_span,
1096
+ is_removable=is_removable,
1097
+ is_renamable=is_renamable,
1098
+ source_entry_id=source_entry_id,
1099
+ clear_source_entry_id=clear_source_entry_id,
1100
+ is_hidden=is_hidden,
1101
+ is_static_view=is_static_View,
1102
+ is_shown_in_template=is_shown_in_template,
1103
+ template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1104
+ clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1105
+ entry_options_map=entry_options_map)
1106
+
1107
+ # FR-47468: Some functions that can help with entry updates.
1108
+ def force_step_update_params(self, step: Step, *,
1109
+ entry_name: str | None = None,
1110
+ related_entry_set: Iterable[int] | None = None,
1111
+ dependency_set: Iterable[int] | None = None,
1112
+ entry_status: ExperimentEntryStatus | None = None,
1113
+ order: int | None = None,
1114
+ description: str | None = None,
1115
+ requires_grabber_plugin: bool | None = None,
1116
+ is_initialization_required: bool | None = None,
1117
+ notebook_experiment_tab_id: int | None = None,
1118
+ entry_height: int | None = None,
1119
+ column_order: int | None = None,
1120
+ column_span: int | None = None,
1121
+ is_removable: bool | None = None,
1122
+ is_renamable: bool | None = None,
1123
+ source_entry_id: int | None = None,
1124
+ clear_source_entry_id: bool | None = None,
1125
+ is_hidden: bool | None = None,
1126
+ is_static_view: bool | None = None,
1127
+ is_shown_in_template: bool | None = None,
1128
+ template_item_fulfilled_timestamp: int | None = None,
1129
+ clear_template_item_fulfilled_timestamp: bool | None = None,
1130
+ entry_options_map: dict[str, str] | None = None) -> None:
1131
+ """
1132
+ Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
1133
+ of the Step that is being updated.
1134
+
1135
+ Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1136
+
1137
+ If no step functions have been called before and a step is being searched for by name, queries for the
1138
+ list of steps in the experiment and caches them.
1139
+
1140
+ :param step:
1141
+ The entry step to update.
1142
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1143
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1144
+ :param entry_name: The new name of this entry.
1145
+ :param related_entry_set: The new set of entry IDs for the entries that are related (implicitly dependent) to
1146
+ this entry. Completely overwrites the existing related entries.
1147
+ :param dependency_set: The new set of entry IDs for the entries that are dependent (explicitly dependent) on
1148
+ this entry. Completely overwrites the existing dependent entries.
1149
+ :param entry_status: The new status of this entry.
1150
+ :param order: The row order of this entry in its tab.
1151
+ :param description: The new description of this entry.
1152
+ :param requires_grabber_plugin: Whether this entry's initialization is handled by a grabber plugin. If true,
1153
+ then is_initialization_required is forced to true by the server.
1154
+ :param is_initialization_required: Whether the user is required to manually initialize this entry.
1155
+ :param notebook_experiment_tab_id: The ID of the tab that this entry should appear on.
1156
+ :param entry_height: The height of this entry.
1157
+ :param column_order: The column order of this entry.
1158
+ :param column_span: How many columns this entry spans.
1159
+ :param is_removable: Whether this entry can be removed by the user.
1160
+ :param is_renamable: Whether this entry can be renamed by the user.
1161
+ :param source_entry_id: The ID of this entry from its template.
1162
+ :param clear_source_entry_id: True if the source entry ID should be cleared.
1163
+ :param is_hidden: Whether this entry is hidden from the user.
1164
+ :param is_static_view: Whether this entry is static. Static entries are uneditable and shared across all
1165
+ experiments of the same template.
1166
+ :param is_shown_in_template: Whether this entry is saved to and shown in the experiment's template.
1167
+ :param template_item_fulfilled_timestamp: A timestamp in milliseconds for when this entry was initialized.
1168
+ :param clear_template_item_fulfilled_timestamp: True if the template item fulfilled timestamp should be cleared,
1169
+ uninitializing the entry.
1170
+ :param entry_options_map:
1171
+ The new map of options for this entry. Completely overwrites the existing options map.
1172
+ Any changes to the entry options will update this ExperimentHandler's cache of entry options.
1173
+ If you wish to add options to the existing map of options that an entry has, use the
1174
+ add_step_options method.
1175
+ """
1176
+ update = self._criteria_from_params(step,
1177
+ entry_name=entry_name,
1178
+ related_entry_set=related_entry_set,
1179
+ dependency_set=dependency_set,
1180
+ entry_status=entry_status,
1181
+ order=order,
1182
+ description=description,
1183
+ requires_grabber_plugin=requires_grabber_plugin,
1184
+ is_initialization_required=is_initialization_required,
1185
+ notebook_experiment_tab_id=notebook_experiment_tab_id,
1186
+ entry_height=entry_height,
1187
+ column_order=column_order,
1188
+ column_span=column_span,
1189
+ is_removable=is_removable,
1190
+ is_renamable=is_renamable,
1191
+ source_entry_id=source_entry_id,
1192
+ clear_source_entry_id=clear_source_entry_id,
1193
+ is_hidden=is_hidden,
1194
+ is_static_view=is_static_view,
1195
+ is_shown_in_template=is_shown_in_template,
1196
+ template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1197
+ clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1198
+ entry_options_map=entry_options_map)
1199
+ self.force_step_update(step, update)
1200
+
1201
+ def force_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1202
+ """
1203
+ Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
1204
+ of the Step that is being updated.
1205
+
1206
+ Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1207
+
1208
+ If no step functions have been called before and a step is being searched for by name, queries for the
1209
+ list of steps in the experiment and caches them.
1210
+
1211
+ :param step: The step to update.
1212
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1213
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1214
+ :param update: The update to make to the step.
1215
+ """
1216
+ step = self.__to_eln_step(step)
1217
+ self._eln_man.update_experiment_entry(self._exp_id, step.get_id(), update)
1218
+ self._update_entry_details(step, update)
1219
+
1220
+ def store_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1221
+ """
1222
+ Store updates to be made to an entry in this experiment. The updates are not committed until
1223
+ commit_entry_updates is called.
1224
+
1225
+ If the same entry is updated multiple times before committing, the latest update will be merged on top of the
1226
+ previous updates; where the new update and old update conflict, the new update will take precedence.
1227
+
1228
+ If no step functions have been called before and a step is being searched for by name, queries for the
1229
+ list of steps in the experiment and caches them.
1230
+
1231
+ :param step: The step to update.
1232
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1233
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1234
+ :param update: The update to make to the step.
1235
+ """
1236
+ step = self.__to_eln_step(step)
1237
+ if step.eln_entry.entry_type != update.entry_type:
1238
+ raise SapioException(f"The provided step and update criteria are not of the same entry type. "
1239
+ f"The step is of type {step.eln_entry.entry_type} and the update criteria is of type "
1240
+ f"{update.entry_type}.")
1241
+ if step.get_id() in self._step_updates:
1242
+ self._merge_updates(update, self._step_updates[step.get_id()])
1243
+ else:
1244
+ self._step_updates[step.get_id()] = update
1245
+
1246
+ def store_step_updates(self, updates: dict[Step, AbstractElnEntryUpdateCriteria]) -> None:
1247
+ """
1248
+ Store updates to be made to multiple entries in this experiment. The updates are not committed until
1249
+ commit_entry_updates is called.
1250
+
1251
+ If the same entry is updated multiple times before committing, the latest update will be merged on top of the
1252
+ previous updates; where the new update and old update conflict, the new update will take precedence.
1253
+
1254
+ If no step functions have been called before and a step is being searched for by name, queries for the
1255
+ list of steps in the experiment and caches them.
1256
+
1257
+ :param updates: A dictionary of steps and their respective updates.
1258
+ """
1259
+ for step, update in updates.items():
1260
+ self.store_step_update(step, update)
1261
+
1262
+ def commit_step_updates(self) -> None:
1263
+ """
1264
+ Commit all the stored updates to the entries in this experiment. The updates are made in the order that they
1265
+ were stored.
1266
+ """
1267
+ self._eln_man.update_experiment_entries(self._exp_id, self._step_updates)
1268
+ for step_id, criteria in self._step_updates.items():
1269
+ self._update_entry_details(self._steps_by_id[step_id], criteria)
1270
+ self._step_updates.clear()
1271
+
1272
+ def _criteria_from_params(self, step: Step, *,
1273
+ entry_name: str | None = None,
1274
+ related_entry_set: Iterable[int] | None = None,
1275
+ dependency_set: Iterable[int] | None = None,
1276
+ entry_status: ExperimentEntryStatus | None = None,
1277
+ order: int | None = None,
1278
+ description: str | None = None,
1279
+ requires_grabber_plugin: bool | None = None,
1280
+ is_initialization_required: bool | None = None,
1281
+ notebook_experiment_tab_id: int | None = None,
1282
+ entry_height: int | None = None,
1283
+ column_order: int | None = None,
1284
+ column_span: int | None = None,
1285
+ is_removable: bool | None = None,
1286
+ is_renamable: bool | None = None,
1287
+ source_entry_id: int | None = None,
1288
+ clear_source_entry_id: bool | None = None,
1289
+ is_hidden: bool | None = None,
1290
+ is_static_view: bool | None = None,
1291
+ is_shown_in_template: bool | None = None,
1292
+ template_item_fulfilled_timestamp: int | None = None,
1293
+ clear_template_item_fulfilled_timestamp: bool | None = None,
1294
+ entry_options_map: dict[str, str] | None = None) -> AbstractElnEntryUpdateCriteria:
1295
+ """
1296
+ Create an abstract update criteria object from the provided parameters for the given step.
1297
+ """
930
1298
  step: ElnEntryStep = self.__to_eln_step(step)
931
- criteria = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1299
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
932
1300
 
933
1301
  # These two variables could be iterables that aren't lists. Convert them to plain
934
1302
  # lists, since that's what the update criteria is expecting.
@@ -937,81 +1305,154 @@ class ExperimentHandler:
937
1305
  if dependency_set is not None:
938
1306
  dependency_set = list(dependency_set)
939
1307
 
940
- criteria.entry_name = entry_name
941
- criteria.related_entry_set = related_entry_set
942
- criteria.dependency_set = dependency_set
943
- criteria.entry_status = entry_status
944
- criteria.order = order
945
- criteria.description = description
946
- criteria.requires_grabber_plugin = requires_grabber_plugin
947
- criteria.is_initialization_required = is_initialization_required
948
- criteria.notebook_experiment_tab_id = notebook_experiment_tab_id
949
- criteria.entry_height = entry_height
950
- criteria.column_order = column_order
951
- criteria.column_span = column_span
952
- criteria.is_removable = is_removable
953
- criteria.is_renamable = is_renamable
954
- criteria.source_entry_id = source_entry_id
955
- criteria.clear_source_entry_id = clear_source_entry_id
956
- criteria.is_hidden = is_hidden
957
- criteria.is_static_View = is_static_View
958
- criteria.is_shown_in_template = is_shown_in_template
959
- criteria.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
960
- criteria.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
961
- criteria.entry_options_map = entry_options_map
962
-
963
- self.__eln_man.update_experiment_entry(self.__exp_id, step.get_id(), criteria)
964
-
965
- # Update the cached information for this entry in case it's needed by the caller after updating.
1308
+ update.entry_name = entry_name
1309
+ update.related_entry_set = related_entry_set
1310
+ update.dependency_set = dependency_set
1311
+ update.entry_status = entry_status
1312
+ update.order = order
1313
+ update.description = description
1314
+ update.requires_grabber_plugin = requires_grabber_plugin
1315
+ update.is_initialization_required = is_initialization_required
1316
+ update.notebook_experiment_tab_id = notebook_experiment_tab_id
1317
+ update.entry_height = entry_height
1318
+ update.column_order = column_order
1319
+ update.column_span = column_span
1320
+ update.is_removable = is_removable
1321
+ update.is_renamable = is_renamable
1322
+ update.source_entry_id = source_entry_id
1323
+ update.clear_source_entry_id = clear_source_entry_id
1324
+ update.is_hidden = is_hidden
1325
+ update.is_static_View = is_static_view
1326
+ update.is_shown_in_template = is_shown_in_template
1327
+ update.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1328
+ update.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
1329
+ update.entry_options_map = entry_options_map
1330
+
1331
+ return update
1332
+
1333
+ @staticmethod
1334
+ def _merge_updates(new_update: AbstractElnEntryUpdateCriteria, old_update: AbstractElnEntryUpdateCriteria) -> None:
1335
+ """
1336
+ Merge the new update criteria onto the old update criteria. The new update will take precedence where there
1337
+ are conflicts.
1338
+ """
1339
+ for key, value in new_update.__dict__.items():
1340
+ if value is not None:
1341
+ old_update.__dict__[key] = value
1342
+
1343
+ def _update_entry_details(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1344
+ """
1345
+ Update the cached information for this entry in case it's needed by the caller after updating.
1346
+ """
966
1347
  entry: ExperimentEntry = step.eln_entry
967
- if entry_name is not None:
1348
+ if update.entry_name is not None:
968
1349
  # PR-46477 - Ensure that the previous name of the updated entry already existed in the cache.
969
- if entry.entry_name in self.__steps:
970
- self.__steps.pop(entry.entry_name)
971
- entry.entry_name = entry_name
972
- self.__steps.update({entry_name: step})
973
- if related_entry_set is not None:
974
- entry.related_entry_id_set = related_entry_set
975
- if dependency_set is not None:
976
- entry.dependency_set = dependency_set
977
- if entry_status is not None:
978
- entry.entry_status = entry_status
979
- if order is not None:
980
- entry.order = order
981
- if description is not None:
982
- entry.description = description
983
- if requires_grabber_plugin is not None:
984
- entry.requires_grabber_plugin = requires_grabber_plugin
985
- if is_initialization_required is not None:
986
- entry.is_initialization_required = is_initialization_required
987
- if notebook_experiment_tab_id is not None:
988
- entry.notebook_experiment_tab_id = notebook_experiment_tab_id
989
- if entry_height is not None:
990
- entry.entry_height = entry_height
991
- if column_order is not None:
992
- entry.column_order = column_order
993
- if column_span is not None:
994
- entry.column_span = column_span
995
- if is_removable is not None:
996
- entry.is_removable = is_removable
997
- if is_renamable is not None:
998
- entry.is_renamable = is_renamable
999
- if source_entry_id is not None:
1000
- entry.source_entry_id = source_entry_id
1001
- if clear_source_entry_id is True:
1350
+ if entry.entry_name in self._steps:
1351
+ self._steps.pop(entry.entry_name)
1352
+ entry.entry_name = update.entry_name
1353
+ self._steps.update({update.entry_name: step})
1354
+ if update.related_entry_set is not None:
1355
+ entry.related_entry_id_set = update.related_entry_set
1356
+ if update.dependency_set is not None:
1357
+ entry.dependency_set = update.dependency_set
1358
+ if update.entry_status is not None:
1359
+ entry.entry_status = update.entry_status
1360
+ if update.order is not None:
1361
+ entry.order = update.order
1362
+ if update.description is not None:
1363
+ entry.description = update.description
1364
+ if update.requires_grabber_plugin is not None:
1365
+ entry.requires_grabber_plugin = update.requires_grabber_plugin
1366
+ if update.is_initialization_required is not None:
1367
+ entry.is_initialization_required = update.is_initialization_required
1368
+ if update.notebook_experiment_tab_id is not None:
1369
+ entry.notebook_experiment_tab_id = update.notebook_experiment_tab_id
1370
+ if update.entry_height is not None:
1371
+ entry.entry_height = update.entry_height
1372
+ if update.column_order is not None:
1373
+ entry.column_order = update.column_order
1374
+ if update.column_span is not None:
1375
+ entry.column_span = update.column_span
1376
+ if update.is_removable is not None:
1377
+ entry.is_removable = update.is_removable
1378
+ if update.is_renamable is not None:
1379
+ entry.is_renamable = update.is_renamable
1380
+ if update.source_entry_id is not None:
1381
+ entry.source_entry_id = update.source_entry_id
1382
+ if update.clear_source_entry_id is True:
1002
1383
  entry.source_entry_id = None
1003
- if is_hidden is not None:
1004
- entry.is_hidden = is_hidden
1005
- if is_static_View is not None:
1006
- entry.is_static_View = is_static_View
1007
- if is_shown_in_template is not None:
1008
- entry.is_shown_in_template = is_shown_in_template
1009
- if template_item_fulfilled_timestamp is not None:
1010
- entry.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1011
- if clear_template_item_fulfilled_timestamp is True:
1384
+ if update.is_hidden is not None:
1385
+ entry.is_hidden = update.is_hidden
1386
+ if update.is_static_View is not None:
1387
+ entry.is_static_View = update.is_static_View
1388
+ if update.is_shown_in_template is not None:
1389
+ entry.is_shown_in_template = update.is_shown_in_template
1390
+ if update.template_item_fulfilled_timestamp is not None:
1391
+ entry.template_item_fulfilled_timestamp = update.template_item_fulfilled_timestamp
1392
+ if update.clear_template_item_fulfilled_timestamp is True:
1012
1393
  entry.template_item_fulfilled_timestamp = None
1013
- if entry_options_map is not None:
1014
- self.__step_options.update({step.get_id(): entry_options_map})
1394
+ if update.entry_options_map is not None:
1395
+ self._step_options.update({step.get_id(): update.entry_options_map})
1396
+
1397
+ if isinstance(entry, ExperimentAttachmentEntry) and isinstance(update, ElnAttachmentEntryUpdateCriteria):
1398
+ if update.entry_attachment_list is not None:
1399
+ entry.entry_attachment_list = update.entry_attachment_list
1400
+ if update.record_id is not None:
1401
+ entry.record_id = update.record_id
1402
+ if update.attachment_name is not None:
1403
+ entry.attachment_name = update.attachment_name
1404
+ elif isinstance(entry, ExperimentDashboardEntry) and isinstance(update, ElnDashboardEntryUpdateCriteria):
1405
+ if update.dashboard_guid is not None:
1406
+ entry.dashboard_guid = update.dashboard_guid
1407
+ if update.dashboard_guid_list is not None:
1408
+ entry.dashboard_guid_list = update.dashboard_guid_list
1409
+ if update.data_source_entry_id is not None:
1410
+ entry.data_source_entry_id = update.data_source_entry_id
1411
+ elif isinstance(entry, ExperimentFormEntry) and isinstance(update, ElnFormEntryUpdateCriteria):
1412
+ if update.record_id is not None:
1413
+ entry.record_id = update.record_id
1414
+ if update.form_name_list is not None:
1415
+ entry.form_name_list = update.form_name_list
1416
+ if update.data_type_layout_name is not None:
1417
+ entry.data_type_layout_name = update.data_type_layout_name
1418
+ if update.field_set_id_list is not None:
1419
+ entry.field_set_id_list = update.field_set_id_list
1420
+ if update.extension_type_list is not None:
1421
+ entry.extension_type_list = update.extension_type_list
1422
+ if update.data_field_name_list is not None:
1423
+ entry.data_field_name_list = update.data_field_name_list
1424
+ if update.is_existing_field_removable is not None:
1425
+ entry.is_existing_field_removable = update.is_existing_field_removable
1426
+ if update.is_field_addable is not None:
1427
+ entry.is_field_addable = update.is_field_addable
1428
+ elif isinstance(entry, ExperimentPluginEntry) and isinstance(update, ElnPluginEntryUpdateCriteria):
1429
+ if update.plugin_name is not None:
1430
+ entry.plugin_name = update.plugin_name
1431
+ if update.provides_template_data is not None:
1432
+ entry.provides_template_data = update.provides_template_data
1433
+ if update.using_template_data is not None:
1434
+ entry.using_template_data = update.using_template_data
1435
+ if isinstance(entry, ExperimentTableEntry) and isinstance(update, ElnTableEntryUpdateCriteria):
1436
+ if update.data_type_layout_name is not None:
1437
+ entry.data_type_layout_name = update.data_type_layout_name
1438
+ if update.extension_type_list is not None:
1439
+ entry.extension_type_list = update.extension_type_list
1440
+ if update.field_set_id_list is not None:
1441
+ entry.field_set_id_list = update.field_set_id_list
1442
+ if update.is_existing_field_removable is not None:
1443
+ entry.is_existing_field_removable = update.is_existing_field_removable
1444
+ if update.is_field_addable is not None:
1445
+ entry.is_field_addable = update.is_field_addable
1446
+ if update.show_key_fields is not None:
1447
+ entry.show_key_fields = update.show_key_fields
1448
+ if update.table_column_list is not None:
1449
+ entry.table_column_list = update.table_column_list
1450
+ elif isinstance(entry, ExperimentTempDataEntry) and isinstance(update, ElnTempDataEntryUpdateCriteria):
1451
+ if update.plugin_path is not None:
1452
+ entry.plugin_path = update.plugin_path
1453
+ elif isinstance(entry, ExperimentTextEntry) and isinstance(update, ElnTextEntryUpdateCriteria):
1454
+ # Text update criteria has no additional fields.
1455
+ pass
1015
1456
 
1016
1457
  def get_step_option(self, step: Step, option: str) -> str:
1017
1458
  """
@@ -1051,9 +1492,10 @@ class ExperimentHandler:
1051
1492
  :return: The map of options for the input step.
1052
1493
  """
1053
1494
  step = self.__to_eln_step(step)
1054
- if step not in self.__step_options:
1055
- self.__step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user, self.get_all_steps()))
1056
- return self.__step_options[step.get_id()]
1495
+ if step not in self._step_options:
1496
+ self._step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user,
1497
+ self.get_all_steps()))
1498
+ return self._step_options[step.get_id()]
1057
1499
 
1058
1500
  def add_step_options(self, step: Step, mapping: Mapping[str, str]):
1059
1501
  """
@@ -1077,7 +1519,7 @@ class ExperimentHandler:
1077
1519
  """
1078
1520
  options: dict[str, str] = self.get_step_options(step)
1079
1521
  options.update(mapping)
1080
- self.update_step(step, entry_options_map=options)
1522
+ self.force_step_update_params(step, entry_options_map=options)
1081
1523
 
1082
1524
  def initialize_step(self, step: Step) -> None:
1083
1525
  """
@@ -1095,7 +1537,7 @@ class ExperimentHandler:
1095
1537
  # Avoid unnecessary calls if the step is already initialized.
1096
1538
  step: ElnEntryStep = self.__to_eln_step(step)
1097
1539
  if step.eln_entry.template_item_fulfilled_timestamp is None:
1098
- self.update_step(step, template_item_fulfilled_timestamp=round(time.time() * 1000))
1540
+ self.force_step_update_params(step, template_item_fulfilled_timestamp=TimeUtil.now_in_millis())
1099
1541
 
1100
1542
  def uninitialize_step(self, step: Step) -> None:
1101
1543
  """
@@ -1113,7 +1555,7 @@ class ExperimentHandler:
1113
1555
  # Avoid unnecessary calls if the step is already uninitialized.
1114
1556
  step: ElnEntryStep = self.__to_eln_step(step)
1115
1557
  if step.eln_entry.template_item_fulfilled_timestamp is not None:
1116
- self.update_step(step, clear_template_item_fulfilled_timestamp=True)
1558
+ self.force_step_update_params(step, clear_template_item_fulfilled_timestamp=True)
1117
1559
 
1118
1560
  def complete_step(self, step: Step) -> None:
1119
1561
  """
@@ -1129,7 +1571,7 @@ class ExperimentHandler:
1129
1571
  If given a name, throws an exception if no step of the given name exists in the experiment.
1130
1572
  """
1131
1573
  step = self.__to_eln_step(step)
1132
- if step.eln_entry.entry_status not in self.__ENTRY_COMPLETE_STATUSES:
1574
+ if step.eln_entry.entry_status not in self._ENTRY_COMPLETE_STATUSES:
1133
1575
  step.complete_step()
1134
1576
  step.eln_entry.entry_status = ExperimentEntryStatus.Completed
1135
1577
 
@@ -1147,7 +1589,7 @@ class ExperimentHandler:
1147
1589
  If given a name, throws an exception if no step of the given name exists in the experiment.
1148
1590
  """
1149
1591
  step = self.__to_eln_step(step)
1150
- if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
1592
+ if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1151
1593
  step.unlock_step()
1152
1594
  step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired
1153
1595
 
@@ -1169,8 +1611,8 @@ class ExperimentHandler:
1169
1611
  If given a name, throws an exception if no step of the given name exists in the experiment.
1170
1612
  """
1171
1613
  step = self.__to_eln_step(step)
1172
- if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
1173
- self.update_step(step, entry_status=ExperimentEntryStatus.Disabled)
1614
+ if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1615
+ self.force_step_update_params(step, entry_status=ExperimentEntryStatus.Disabled)
1174
1616
 
1175
1617
  def step_is_submitted(self, step: Step) -> bool:
1176
1618
  """
@@ -1185,7 +1627,7 @@ class ExperimentHandler:
1185
1627
  If given a name, throws an exception if no step of the given name exists in the experiment.
1186
1628
  :return: True if the step's status is Completed or CompletedApproved. False otherwise.
1187
1629
  """
1188
- return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_COMPLETE_STATUSES
1630
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_COMPLETE_STATUSES
1189
1631
 
1190
1632
  def step_is_locked(self, step: Step) -> bool:
1191
1633
  """
@@ -1201,7 +1643,494 @@ class ExperimentHandler:
1201
1643
  :return: True if the step's status is Completed, CompletedApproved, Disabled, LockedAwaitingApproval,
1202
1644
  or LockedRejected. False otherwise.
1203
1645
  """
1204
- return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES
1646
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES
1647
+
1648
+ # FR-47464: Some functions that can help with entry placement.
1649
+ def get_all_tabs(self) -> list[ElnExperimentTab]:
1650
+ """
1651
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1652
+ list of tabs in the experiment and caches them.
1653
+
1654
+ :return: A list of all the tabs in the experiment in order of appearance.
1655
+ """
1656
+ if not self._queried_all_tabs:
1657
+ self._tabs = self._eln_man.get_tabs_for_experiment(self._exp_id)
1658
+ self._tabs.sort(key=lambda t: t.tab_order)
1659
+ self._tabs_by_name = {tab.tab_name: tab for tab in self._tabs}
1660
+ return self._tabs
1661
+
1662
+ def get_first_tab(self) -> ElnExperimentTab:
1663
+ """
1664
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1665
+ list of tabs in the experiment and caches them.
1666
+
1667
+ :return: The first tab in the experiment.
1668
+ """
1669
+ return self.get_all_tabs()[0]
1670
+
1671
+ def get_last_tab(self) -> ElnExperimentTab:
1672
+ """
1673
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1674
+ list of tabs in the experiment and caches them.
1675
+
1676
+ :return: The last tab in the experiment.
1677
+ """
1678
+ return self.get_all_tabs()[-1]
1679
+
1680
+ def create_tab(self, tab_name: str) -> ElnExperimentTab:
1681
+ """
1682
+ Create a new tab in the experiment with the input name.
1683
+
1684
+ :param tab_name: The name of the tab to create.
1685
+ :return: The newly created tab.
1686
+ """
1687
+ crit = ElnExperimentTabAddCriteria(tab_name, [])
1688
+ tab: ElnExperimentTab = self._eln_man.add_tab_for_experiment(self._exp_id, crit)
1689
+ self.add_tab_to_cache(tab)
1690
+ return tab
1691
+
1692
+ def get_tab(self, tab_name: str) -> ElnExperimentTab:
1693
+ """
1694
+ Return the tab with the input name.
1695
+
1696
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1697
+ list of tabs in the experiment and caches them.
1698
+
1699
+ :param tab_name: The name of the tab to get.
1700
+ :return: The tab with the input name.
1701
+ """
1702
+ if tab_name not in self._tabs_by_name:
1703
+ self.get_all_tabs()
1704
+ return self._tabs_by_name[tab_name]
1705
+
1706
+ def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
1707
+ """
1708
+ Get all the steps in the input tab sorted in order of appearance.
1709
+
1710
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1711
+ list of tabs in the experiment and caches them.
1712
+
1713
+ If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1714
+ and caches them.
1715
+
1716
+ :param tab: The tab or tab name to get the steps of.
1717
+ :param data_type: The data type to filter the steps by. If None, all steps are returned.
1718
+ :return: A list of all the steps in the input tab sorted in order of appearance.
1719
+ """
1720
+ tab: ElnExperimentTab = self.__to_eln_tab(tab)
1721
+ steps: list[ElnEntryStep] = []
1722
+ for step in self.get_all_steps(data_type):
1723
+ if step.eln_entry.notebook_experiment_tab_id == tab.tab_id:
1724
+ steps.append(step)
1725
+ steps.sort(key=lambda s: (s.eln_entry.order, s.eln_entry.column_order))
1726
+ return steps
1727
+
1728
+ def get_next_entry_order_in_tab(self, tab: Tab) -> int:
1729
+ """
1730
+ Get the next available order for a new entry in the input tab.
1731
+
1732
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1733
+ list of tabs in the experiment and caches them.
1734
+
1735
+ If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1736
+ and caches them.
1737
+
1738
+ :param tab: The tab or tab name to get the steps of.
1739
+ :return: The next available order for a new entry in the input tab.
1740
+ """
1741
+ steps = self.get_steps_in_tab(tab)
1742
+ return steps[-1].eln_entry.order + 1 if steps else 0
1743
+
1744
+ # FR-47468: Add functions for creating new entries in the experiment.
1745
+ def create_attachment_step(self, entry_name: str, data_type: DataTypeIdentifier,
1746
+ *,
1747
+ position: ElnEntryPosition | None = None,
1748
+ attachments: list[EntryAttachment] | list[SapioRecord] | None = None) -> ElnEntryStep:
1749
+ """
1750
+ Create a new attachment entry in the experiment.
1751
+
1752
+ :param entry_name: The name of the entry.
1753
+ :param data_type: The data type of the entry.
1754
+ :param position: Information about where to place the entry in the experiment.
1755
+ :param attachments: The list of attachments to initially populate the entry with.
1756
+ :return: The newly created attachment entry.
1757
+ """
1758
+ step = self._create_step(ElnEntryType.Attachment, entry_name, data_type, position)
1759
+ if attachments:
1760
+ entry_attachments: list[EntryAttachment] = []
1761
+ for entry in attachments:
1762
+ if isinstance(entry, EntryAttachment):
1763
+ entry_attachments.append(entry)
1764
+ elif isinstance(entry, SapioRecord):
1765
+ entry: SapioRecord
1766
+ file_name: str = entry.get_field_value("FilePath")
1767
+ if not file_name:
1768
+ file_name = entry.get_field_value(SystemFields.DATA_RECORD_NAME__FIELD.field_name)
1769
+ rec_id: int = AliasUtil.to_record_id(entry)
1770
+ entry_attachments.append(EntryRecordAttachment(file_name, rec_id))
1771
+ else:
1772
+ raise SapioException("Attachments must be of type EntryAttachment or SapioRecord.")
1773
+ update = ElnAttachmentEntryUpdateCriteria()
1774
+ update.entry_attachment_list = attachments
1775
+ self.force_step_update(step, update)
1776
+ return step
1777
+
1778
+ def create_form_step(self, entry_name: str,
1779
+ data_type: DataTypeIdentifier,
1780
+ fields: list[FieldIdentifier] | None = None,
1781
+ layout_name: str | None = None,
1782
+ form_names: list[str] | None = None,
1783
+ *,
1784
+ position: ElnEntryPosition | None = None,
1785
+ record: SapioRecord | None = None) -> ElnEntryStep:
1786
+ """
1787
+ Create a new form entry in the experiment.
1788
+
1789
+ :param entry_name: The name of the entry.
1790
+ :param data_type: The data type of the entry.
1791
+ :param fields: The list of data field names for the given data type that should appear on the
1792
+ form. Fields will appear in the order they are provided. If not provided and a layout name is not provided,
1793
+ the entry will be created with the data type's default layout.
1794
+ :param layout_name: The name of the layout to use for the entry. If not provided and field names are not
1795
+ provided, the entry will be created with the data type's default layout.
1796
+ :param form_names: The list of layout component form names to use from the specified layout name. If not
1797
+ provided, then all available forms from the specified layout will be used.
1798
+ :param position: Information about where to place the entry in the experiment.
1799
+ :param record: The record to initially populate the entry with.
1800
+ :return: The newly created form entry.
1801
+ """
1802
+ if record:
1803
+ rdt: str = AliasUtil.to_data_type_name(record)
1804
+ sdt: str = AliasUtil.to_data_type_name(data_type)
1805
+ if rdt != sdt:
1806
+ raise SapioException(f"Cannot set {rdt} records for entry {entry_name} of type "
1807
+ f"{sdt}.")
1808
+
1809
+ step = self._create_step(ElnEntryType.Form, entry_name, data_type, position)
1810
+ if fields or layout_name or form_names or record:
1811
+ update = ElnFormEntryUpdateCriteria()
1812
+ if fields:
1813
+ update.data_field_name_list = AliasUtil.to_data_field_names(fields)
1814
+ update.data_type_layout_name = layout_name
1815
+ update.form_name_list = form_names
1816
+ if record:
1817
+ update.record_id = AliasUtil.to_record_id(record)
1818
+ self.force_step_update(step, update)
1819
+ return step
1820
+
1821
+ def create_experiment_detail_form_step(self, entry_name: str,
1822
+ fields: list[ElnDataTypeFields] | None = None,
1823
+ field_map: FieldMap | None = None,
1824
+ *,
1825
+ position: ElnEntryPosition | None = None,
1826
+ is_field_addable: bool | None = None,
1827
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1828
+ """
1829
+ Create a new ELN experiment details form entry in the experiment.
1830
+
1831
+ :param entry_name: The name of the entry.
1832
+ :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1833
+ will appear in the order they are provided.
1834
+ :param field_map: A field map that will be used to populate the entry. The data field names in
1835
+ the map must match the field names of the provided field definitions.
1836
+ :param position: Information about where to place the entry in the experiment.
1837
+ :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1838
+ :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1839
+ :return: The newly created form entry.
1840
+ """
1841
+ return self._create_eln_dt_form_step(entry_name, ElnBaseDataType.EXPERIMENT_DETAIL, fields, field_map,
1842
+ position=position,
1843
+ is_field_addable=is_field_addable,
1844
+ is_existing_field_removable=is_existing_field_removable)
1845
+
1846
+ def create_sample_detail_form_step(self, entry_name: str,
1847
+ fields: list[ElnDataTypeFields] | None = None,
1848
+ field_map: FieldMap | None = None,
1849
+ *,
1850
+ position: ElnEntryPosition | None = None,
1851
+ is_field_addable: bool | None = None,
1852
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1853
+ """
1854
+ Create a new ELN sample details form entry in the experiment.
1855
+
1856
+ :param entry_name: The name of the entry.
1857
+ :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1858
+ will appear in the order they are provided.
1859
+ :param field_map: A field map that will be used to populate the entry. The data field names in
1860
+ the map must match the field names of the provided field definitions.
1861
+ :param position: Information about where to place the entry in the experiment.
1862
+ :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1863
+ :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1864
+ :return: The newly created form entry.
1865
+ """
1866
+ return self._create_eln_dt_form_step(entry_name, ElnBaseDataType.SAMPLE_DETAIL, fields, field_map,
1867
+ position=position,
1868
+ is_field_addable=is_field_addable,
1869
+ is_existing_field_removable=is_existing_field_removable)
1870
+
1871
+ def _create_eln_dt_form_step(self, entry_name: str,
1872
+ dt: ElnBaseDataType,
1873
+ fields: list[ElnDataTypeFields] | None = None,
1874
+ field_map: FieldMap | None = None,
1875
+ *,
1876
+ position: ElnEntryPosition | None = None,
1877
+ is_field_addable: bool | None = None,
1878
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1879
+ fields: list[AbstractVeloxFieldDefinition] | None = self._to_field_defs(fields, dt)
1880
+ step = self._create_step(ElnEntryType.Form, entry_name, dt.data_type_name,
1881
+ position,
1882
+ field_definition_list=fields,
1883
+ field_map_list=[field_map] if field_map else None)
1884
+ if is_field_addable is not None or is_existing_field_removable is not None:
1885
+ update = ElnFormEntryUpdateCriteria()
1886
+ update.is_field_addable = is_field_addable
1887
+ update.is_existing_field_removable = is_existing_field_removable
1888
+ self.force_step_update(step, update)
1889
+ return step
1890
+
1891
+ def create_plugin_step(self, entry_name: str, data_type: DataTypeIdentifier, plugin_path: str, *,
1892
+ position: ElnEntryPosition | None = None) -> ElnEntryStep:
1893
+ """
1894
+ Create a new plugin entry in the experiment.
1895
+
1896
+ :param entry_name: The name of the entry.
1897
+ :param data_type: The data type of the entry.
1898
+ :param plugin_path: The plugin path to the CSP that determines this entry's functionality.
1899
+ :param position: Information about where to place the entry in the experiment.
1900
+ :return: The newly created plugin entry.
1901
+ """
1902
+ return self._create_step(ElnEntryType.Plugin, entry_name, data_type, position,
1903
+ csp_plugin_name=plugin_path)
1904
+
1905
+ def create_table_step(self, entry_name: str, data_type: DataTypeIdentifier,
1906
+ fields: list[FieldIdentifier] | list[TableColumn] | None = None,
1907
+ layout_name: str | None = None,
1908
+ show_key_fields: bool | None = None,
1909
+ *,
1910
+ position: ElnEntryPosition | None = None,
1911
+ records: list[SapioRecord] | None = None) -> ElnEntryStep:
1912
+ """
1913
+ Create a new table entry in the experiment.
1914
+
1915
+ :param entry_name: The name of the entry.
1916
+ :param data_type: The data type of the entry.
1917
+ :param fields: The list of data field names for the given data type that should appear on the
1918
+ table. You may also provide a list of TableColumns instead of a list of field names for when you want to
1919
+ set the sort order and direction of the columns in the table. Fields will appear in the order they are
1920
+ provided. If not provided and a layout name is not provided, the entry will be created with the data type's
1921
+ default layout.
1922
+ :param layout_name: The name of the layout to use for the entry. If not provided and field names are not
1923
+ provided, the entry will be created with the data type's default layout.
1924
+ :param show_key_fields: Whether the table should only show key fields of the data type.
1925
+ :param position: Information about where to place the entry in the experiment.
1926
+ :param records: The list of records to initially populate the entry with.
1927
+ :return: The newly created table entry.
1928
+ """
1929
+ step = self._create_step(ElnEntryType.Table, entry_name, data_type, position)
1930
+ if fields or layout_name:
1931
+ update = ElnTableEntryUpdateCriteria()
1932
+ if fields:
1933
+ dt: str = AliasUtil.to_data_type_name(data_type)
1934
+ columns: list[TableColumn] = []
1935
+ for field in fields:
1936
+ if isinstance(field, TableColumn):
1937
+ columns.append(field)
1938
+ else:
1939
+ columns.append(TableColumn(dt, AliasUtil.to_data_field_name(field)))
1940
+ update.table_column_list = columns
1941
+ update.data_type_layout_name = layout_name
1942
+ update.show_key_fields = show_key_fields
1943
+ self.force_step_update(step, update)
1944
+ if records:
1945
+ self.set_step_records(step, records)
1946
+ return step
1947
+
1948
+ def create_experiment_detail_table_step(self, entry_name: str,
1949
+ fields: list[ElnDataTypeFields] | None = None,
1950
+ field_maps: list[FieldMap] | None = None, *,
1951
+ position: ElnEntryPosition | None = None,
1952
+ is_field_addable: bool | None = None,
1953
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1954
+ """
1955
+ Create a new ELN experiment details table entry in the experiment.
1956
+
1957
+ :param entry_name: The name of the entry.
1958
+ :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1959
+ will appear in the order they are provided.
1960
+ :param field_maps: A field maps list that will be used to populate the entry. The data field names in
1961
+ the maps must match the field names of the provided field definitions.
1962
+ :param position: Information about where to place the entry in the experiment.
1963
+ :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1964
+ :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1965
+ :return: The newly created table entry.
1966
+ """
1967
+ return self._create_eln_dt_table_step(entry_name, ElnBaseDataType.EXPERIMENT_DETAIL, fields, field_maps,
1968
+ position=position,
1969
+ is_field_addable=is_field_addable,
1970
+ is_existing_field_removable=is_existing_field_removable)
1971
+
1972
+ def create_sample_detail_table_step(self, entry_name: str,
1973
+ fields: list[ElnDataTypeFields] | None = None,
1974
+ field_maps: list[FieldMap] | None = None, *,
1975
+ position: ElnEntryPosition | None = None,
1976
+ is_field_addable: bool | None = None,
1977
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1978
+ """
1979
+ Create a new ELN sample details table entry in the experiment.
1980
+
1981
+ :param entry_name: The name of the entry.
1982
+ :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1983
+ will appear in the order they are provided.
1984
+ :param field_maps: A field maps list that will be used to populate the entry. The data field names in
1985
+ the maps must match the field names of the provided field definitions.
1986
+ :param position: Information about where to place the entry in the experiment.
1987
+ :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1988
+ :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1989
+ :return: The newly created table entry.
1990
+ """
1991
+ return self._create_eln_dt_table_step(entry_name, ElnBaseDataType.SAMPLE_DETAIL, fields, field_maps,
1992
+ position=position,
1993
+ is_field_addable=is_field_addable,
1994
+ is_existing_field_removable=is_existing_field_removable)
1995
+
1996
+ def _create_eln_dt_table_step(self, entry_name: str,
1997
+ dt: ElnBaseDataType,
1998
+ fields: list[ElnDataTypeFields] | None = None,
1999
+ field_maps: list[FieldMap] | None = None, *,
2000
+ position: ElnEntryPosition | None = None,
2001
+ is_field_addable: bool | None = None,
2002
+ is_existing_field_removable: bool | None = None) -> ElnEntryStep:
2003
+ fields: list[AbstractVeloxFieldDefinition] | None = self._to_field_defs(fields, dt)
2004
+ step = self._create_step(ElnEntryType.Table, entry_name, dt.data_type_name,
2005
+ position,
2006
+ field_definition_list=fields,
2007
+ field_map_list=field_maps)
2008
+ if is_field_addable is not None or is_existing_field_removable is not None:
2009
+ update = ElnTableEntryUpdateCriteria()
2010
+ update.is_field_addable = is_field_addable
2011
+ update.is_existing_field_removable = is_existing_field_removable
2012
+ self.force_step_update(step, update)
2013
+ return step
2014
+
2015
+ def create_temp_data_step(self, entry_name: str, data_type: DataTypeIdentifier, plugin_path: str, *,
2016
+ position: ElnEntryPosition | None = None) -> ElnEntryStep:
2017
+ """
2018
+ Create a new temp data entry in the experiment.
2019
+
2020
+ :param entry_name: The name of the entry.
2021
+ :param data_type: The data type of the entry.
2022
+ :param plugin_path: The plugin path to the plugin that populates the entry.
2023
+ :param position: Information about where to place the entry in the experiment.
2024
+ :return: The newly created temp data entry.
2025
+ """
2026
+ return self._create_step(ElnEntryType.TempData, entry_name, data_type, position,
2027
+ temp_data_plugin_path=plugin_path)
2028
+
2029
+ def create_text_step(self, entry_name: str, text: str | None = None, *,
2030
+ position: ElnEntryPosition | None = None) -> ElnEntryStep:
2031
+ """
2032
+ Create a new text entry in the experiment.
2033
+
2034
+ :param entry_name: The name of the entry.
2035
+ :param text: The text to populate the entry with.
2036
+ :param position: Information about where to place the entry in the experiment.
2037
+ :return: The newly created text entry.
2038
+ """
2039
+ step: ElnEntryStep = self._create_step(ElnEntryType.Text, entry_name,
2040
+ ElnBaseDataType.TEXT_ENTRY_DETAIL.data_type_name, position)
2041
+ if text:
2042
+ text_record: DataRecord = self.get_step_records(entry_name)[0]
2043
+ text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), text)
2044
+ DataRecordManager(self.user).commit_data_records([text_record])
2045
+ return step
2046
+
2047
+ def create_plate_designer_step(self, entry_name: str, source_entry: Step, *,
2048
+ position: ElnEntryPosition | None = None) -> ElnEntryStep:
2049
+ """
2050
+ Create a new 3D plate designer entry in the experiment.
2051
+
2052
+ :param entry_name: The name of the entry.
2053
+ :param source_entry: The entry that the plate designer will source its samples from.
2054
+ :param position: Information about where to place the entry in the experiment.
2055
+ :return: The newly created plate designer entry.
2056
+ """
2057
+ step = self.create_plugin_step(entry_name, "Sample", PLATE_DESIGNER_PLUGIN, position=position)
2058
+ default_layer = MultiLayerPlateLayer(
2059
+ MultiLayerDataTypeConfig("Sample"),
2060
+ PlatingOrder.FillBy.BY_COLUMN,
2061
+ MultiLayerReplicateConfig(),
2062
+ MultiLayerDilutionConfig()
2063
+ )
2064
+ initial_step_options: dict[str, str] = {
2065
+ "MultiLayerPlating_Plate_RecordIdList": "",
2066
+ "MultiLayerPlating_Entry_Prefs": MultiLayerPlatingManager.get_entry_prefs_json([default_layer]),
2067
+ "MultiLayerPlating_Entry_PrePlating_Prefs": MultiLayerPlatingManager.get_plate_configs_json(MultiLayerPlateConfig())
2068
+ }
2069
+ source_entry = self.__to_eln_step(source_entry)
2070
+ self.force_step_update_params(step, entry_height=600, entry_options_map=initial_step_options,
2071
+ related_entry_set=[source_entry.get_id()])
2072
+ return step
2073
+
2074
+ # TODO: Update these functions to use entry type-specific creation criteria once Sapiopylib supports that.
2075
+ # This should take in an entry creation criteria object that handles all the abstract attributes that
2076
+ # every entry type shares.
2077
+ def _create_step(self, entry_type: ElnEntryType, entry_name: str, data_type: DataTypeIdentifier,
2078
+ position: ElnEntryPosition | None = None, **kwargs) \
2079
+ -> ElnEntryStep:
2080
+ """
2081
+ Create a new entry in the experiment of the given type.
2082
+ """
2083
+ if position is not None:
2084
+ order: int = position.order
2085
+ tab_id: int = position.tab_id
2086
+ column_order: int = position.column_order
2087
+ column_span: int = position.column_span
2088
+ else:
2089
+ last_tab: ElnExperimentTab = self.get_last_tab()
2090
+ order: int = self.get_next_entry_order_in_tab(last_tab)
2091
+ tab_id: int = last_tab.tab_id
2092
+ column_order: int = 0
2093
+ column_span: int = last_tab.max_number_of_columns
2094
+
2095
+ data_type: str = AliasUtil.to_data_type_name(data_type)
2096
+ crit = ElnEntryCriteria(entry_type, entry_name, data_type, order,
2097
+ notebook_experiment_tab_id=tab_id,
2098
+ column_order=column_order,
2099
+ column_span=column_span,
2100
+ **kwargs)
2101
+ entry: ExperimentEntry = self._eln_man.add_experiment_entry(self._exp_id, crit)
2102
+
2103
+ self.add_entry_to_caches(entry)
2104
+ return ElnEntryStep(self._protocol, entry)
2105
+
2106
+ def _to_field_defs(self, fields: list[ElnDataTypeFields], dt: ElnBaseDataType) \
2107
+ -> list[AbstractVeloxFieldDefinition] | None:
2108
+ """
2109
+ Convert a list of ElnDataTypeField aliases to field definitions.
2110
+ """
2111
+ if not fields:
2112
+ return None
2113
+ field_defs: list[AbstractVeloxFieldDefinition] = []
2114
+ for field in fields:
2115
+ if isinstance(field, AbstractVeloxFieldDefinition):
2116
+ field_defs.append(field)
2117
+ elif isinstance(field, ElnFieldSetInfo):
2118
+ field_defs.extend(self._eln_man.get_predefined_fields_from_field_set_id(field.field_set_id))
2119
+ elif isinstance(field, str):
2120
+ field_defs.append(self._predefined_field(field, dt))
2121
+ elif isinstance(field, int):
2122
+ field_defs.extend(self._eln_man.get_predefined_fields_from_field_set_id(field))
2123
+ return field_defs
2124
+
2125
+ def _predefined_field(self, field_name: str, data_type: ElnBaseDataType) -> AbstractVeloxFieldDefinition:
2126
+ """
2127
+ Get the predefined field of the given name for the given ELN data type.
2128
+ """
2129
+ # TODO: Should this be in some sort of DataTypeCacheManager?
2130
+ if data_type not in self._predefined_fields:
2131
+ fields: list[AbstractVeloxFieldDefinition] = self._eln_man.get_predefined_fields(data_type)
2132
+ self._predefined_fields[data_type.data_type_name] = {x.data_field_name: x for x in fields}
2133
+ return self._predefined_fields[data_type.data_type_name][field_name]
1205
2134
 
1206
2135
  def __to_eln_step(self, step: Step) -> ElnEntryStep:
1207
2136
  """
@@ -1213,13 +2142,12 @@ class ExperimentHandler:
1213
2142
  """
1214
2143
  return self.get_step(step) if isinstance(step, str) else step
1215
2144
 
1216
- def __get_experiment_options(self) -> dict[str, str]:
2145
+ def __to_eln_tab(self, tab: Tab) -> ElnExperimentTab:
1217
2146
  """
1218
- Cache the experiment options if they haven't been cached yet.
2147
+ Convert a variable that could be either a string or an ElnExperimentTab to just an ElnExperimentTab.
2148
+ This will query and cache the tabs for the experiment if the input tab is a name and the tabs have not been
2149
+ cached before.
1219
2150
 
1220
- :return: The options for this experiment.
2151
+ :return: The input tab as an ElnExperimentTab.
1221
2152
  """
1222
- if hasattr(self, "_ExperimentHandler__exp_options"):
1223
- return self.__exp_options
1224
- self.__exp_options = self.__eln_man.get_notebook_experiment_options(self.__exp_id)
1225
- return self.__exp_options
2153
+ return self.get_tab(tab) if isinstance(tab, str) else tab