sapiopycommons 2024.8.15a304__py3-none-any.whl → 2024.8.20a306__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 (33) hide show
  1. sapiopycommons/callbacks/callback_util.py +130 -34
  2. sapiopycommons/customreport/__init__.py +0 -0
  3. sapiopycommons/customreport/column_builder.py +60 -0
  4. sapiopycommons/customreport/custom_report_builder.py +125 -0
  5. sapiopycommons/customreport/term_builder.py +299 -0
  6. sapiopycommons/datatype/attachment_util.py +11 -10
  7. sapiopycommons/eln/experiment_handler.py +209 -44
  8. sapiopycommons/eln/experiment_report_util.py +33 -129
  9. sapiopycommons/files/complex_data_loader.py +5 -4
  10. sapiopycommons/files/file_bridge.py +15 -14
  11. sapiopycommons/files/file_bridge_handler.py +26 -4
  12. sapiopycommons/files/file_data_handler.py +2 -5
  13. sapiopycommons/files/file_util.py +38 -5
  14. sapiopycommons/files/file_validator.py +26 -11
  15. sapiopycommons/files/file_writer.py +44 -15
  16. sapiopycommons/general/aliases.py +147 -3
  17. sapiopycommons/general/audit_log.py +196 -0
  18. sapiopycommons/general/custom_report_util.py +34 -32
  19. sapiopycommons/general/popup_util.py +17 -0
  20. sapiopycommons/general/sapio_links.py +50 -0
  21. sapiopycommons/general/time_util.py +40 -0
  22. sapiopycommons/multimodal/multimodal_data.py +0 -1
  23. sapiopycommons/processtracking/endpoints.py +22 -22
  24. sapiopycommons/recordmodel/record_handler.py +183 -61
  25. sapiopycommons/rules/eln_rule_handler.py +34 -25
  26. sapiopycommons/rules/on_save_rule_handler.py +34 -31
  27. sapiopycommons/webhook/webhook_handlers.py +90 -26
  28. sapiopycommons/webhook/webservice_handlers.py +67 -0
  29. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/METADATA +1 -1
  30. sapiopycommons-2024.8.20a306.dist-info/RECORD +50 -0
  31. sapiopycommons-2024.8.15a304.dist-info/RECORD +0 -43
  32. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/WHEEL +0 -0
  33. {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
- from typing import Any
4
+ from weakref import WeakValueDictionary
3
5
 
4
6
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
5
7
  from sapiopylib.rest.User import SapioUser
@@ -7,7 +9,7 @@ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTer
7
9
  from sapiopylib.rest.pojo.DataRecord import DataRecord
8
10
  from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
9
11
  from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
10
- from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
12
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
11
13
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
12
14
  QueryAllRecordsOfTypeAutoPager
13
15
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
@@ -16,8 +18,10 @@ from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelMana
16
18
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
17
19
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
18
20
  RelationshipNodeType
21
+ from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
19
22
 
20
- from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap
23
+ from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
24
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
21
25
  from sapiopycommons.general.custom_report_util import CustomReportUtil
22
26
  from sapiopycommons.general.exceptions import SapioException
23
27
 
@@ -32,16 +36,38 @@ class RecordHandler:
32
36
  rec_man: RecordModelManager
33
37
  inst_man: RecordModelInstanceManager
34
38
  rel_man: RecordModelRelationshipManager
39
+ an_man: RecordModelAncestorManager
40
+
41
+ __instances: WeakValueDictionary[SapioUser, RecordHandler] = WeakValueDictionary()
42
+ __initialized: bool
43
+
44
+ def __new__(cls, context: UserIdentifier):
45
+ """
46
+ :param context: The current webhook context or a user object to send requests from.
47
+ """
48
+ user = AliasUtil.to_sapio_user(context)
49
+ obj = cls.__instances.get(user)
50
+ if not obj:
51
+ obj = object.__new__(cls)
52
+ obj.__initialized = False
53
+ cls.__instances[user] = obj
54
+ return obj
35
55
 
36
- def __init__(self, context: SapioWebhookContext | SapioUser):
56
+ def __init__(self, context: UserIdentifier):
37
57
  """
38
58
  :param context: The current webhook context or a user object to send requests from.
39
59
  """
60
+ self.user = AliasUtil.to_sapio_user(context)
61
+ if self.__initialized:
62
+ return
63
+ self.__initialized = True
64
+
40
65
  self.user = context if isinstance(context, SapioUser) else context.user
41
66
  self.dr_man = DataRecordManager(self.user)
42
67
  self.rec_man = RecordModelManager(self.user)
43
68
  self.inst_man = self.rec_man.instance_manager
44
69
  self.rel_man = self.rec_man.relationship_manager
70
+ self.an_man = RecordModelAncestorManager(self.rec_man)
45
71
 
46
72
  def wrap_model(self, record: DataRecord, wrapper_type: type[WrappedType]) -> WrappedType:
47
73
  """
@@ -51,6 +77,7 @@ class RecordHandler:
51
77
  :param wrapper_type: The record model wrapper to use.
52
78
  :return: The record model for the input.
53
79
  """
80
+ self.__verify_data_type([record], wrapper_type)
54
81
  return self.inst_man.add_existing_record_of_type(record, wrapper_type)
55
82
 
56
83
  def wrap_models(self, records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> list[WrappedType]:
@@ -61,9 +88,10 @@ class RecordHandler:
61
88
  :param wrapper_type: The record model wrapper to use.
62
89
  :return: The record models for the input.
63
90
  """
91
+ self.__verify_data_type(records, wrapper_type)
64
92
  return self.inst_man.add_existing_records_of_type(list(records), wrapper_type)
65
93
 
66
- def query_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
94
+ def query_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier, value_list: Iterable[FieldValue],
67
95
  page_limit: int | None = None) -> list[WrappedType]:
68
96
  """
69
97
  Shorthand for using the data record manager to query for a list of data records by field value
@@ -77,9 +105,9 @@ class RecordHandler:
77
105
  """
78
106
  return self.query_models_with_criteria(wrapper_type, field, value_list, None, page_limit)[0]
79
107
 
80
- def query_and_map_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
81
- page_limit: int | None = None, *, mapping_field: str | None = None) \
82
- -> dict[Any, list[WrappedType]]:
108
+ def query_and_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
109
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
110
+ *, mapping_field: FieldIdentifier | None = None) -> dict[FieldValue, list[WrappedType]]:
83
111
  """
84
112
  Shorthand for using query_models to search for records given values on a specific field and then using
85
113
  map_by_field to turn the returned list into a dictionary mapping field values to records.
@@ -95,9 +123,10 @@ class RecordHandler:
95
123
  mapping_field = field
96
124
  return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
97
125
 
98
- def query_and_unique_map_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
99
- page_limit: int | None = None, *, mapping_field: str | None = None) \
100
- -> dict[Any, WrappedType]:
126
+ def query_and_unique_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
127
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
128
+ *, mapping_field: FieldIdentifier | None = None) \
129
+ -> dict[FieldValue, WrappedType]:
101
130
  """
102
131
  Shorthand for using query_models to search for records given values on a specific field and then using
103
132
  map_by_unique_field to turn the returned list into a dictionary mapping field values to records.
@@ -114,7 +143,8 @@ class RecordHandler:
114
143
  mapping_field = field
115
144
  return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
116
145
 
117
- def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
146
+ def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
147
+ value_list: Iterable[FieldValue],
118
148
  paging_criteria: DataRecordPojoPageCriteria | None = None,
119
149
  page_limit: int | None = None) \
120
150
  -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
@@ -131,6 +161,7 @@ class RecordHandler:
131
161
  :return: The record models for the queried records and the final paging criteria.
132
162
  """
133
163
  dt: str = wrapper_type.get_wrapper_data_type_name()
164
+ field: str = AliasUtil.to_data_field_name(field)
134
165
  pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
135
166
  pager.max_page = page_limit
136
167
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
@@ -168,6 +199,19 @@ class RecordHandler:
168
199
  pager.max_page = page_limit
169
200
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
170
201
 
202
+ def query_models_by_id_and_map(self, wrapper_type: type[WrappedType], ids: Iterable[int],
203
+ page_limit: int | None = None) -> dict[int, WrappedType]:
204
+ """
205
+ Shorthand for using the data record manager to query for a list of data records by record ID
206
+ and then converting the results into a dictionary of record ID to the record model for that ID.
207
+
208
+ :param wrapper_type: The record model wrapper to use.
209
+ :param ids: The list of record IDs to query.
210
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
211
+ :return: The record models for the queried records mapped in a dictionary by their record ID.
212
+ """
213
+ return {x.record_id: x for x in self.query_models_by_id(wrapper_type, ids, page_limit)}
214
+
171
215
  def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None) -> list[WrappedType]:
172
216
  """
173
217
  Shorthand for using the data record manager to query for all data records of a given type
@@ -200,7 +244,7 @@ class RecordHandler:
200
244
 
201
245
  def query_models_by_report(self, wrapper_type: type[WrappedType],
202
246
  report_name: str | RawReportTerm | CustomReportCriteria,
203
- filters: dict[str, Iterable[Any]] | None = None,
247
+ filters: dict[FieldIdentifierKey, Iterable[FieldValue]] | None = None,
204
248
  page_limit: int | None = None,
205
249
  page_size: int | None = None,
206
250
  page_number: int | None = None) -> list[WrappedType]:
@@ -228,11 +272,11 @@ class RecordHandler:
228
272
  :return: The record models for the queried records that matched the given report.
229
273
  """
230
274
  if isinstance(report_name, str):
231
- results: list[dict[str, Any]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
232
- page_limit, page_size, page_number)
275
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
276
+ page_limit, page_size, page_number)
233
277
  elif isinstance(report_name, RawReportTerm):
234
- results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
235
- page_limit, page_size, page_number)
278
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
279
+ page_limit, page_size, page_number)
236
280
  elif isinstance(report_name, CustomReportCriteria):
237
281
  dt: str = wrapper_type.get_wrapper_data_type_name()
238
282
  # Ensure that the root data type is the one we're looking for.
@@ -243,8 +287,8 @@ class RecordHandler:
243
287
  # Enforce that the given custom report has a record ID column.
244
288
  if not any([x.data_type_name == dt and x.data_field_name == "RecordId" for x in report_name.column_list]):
245
289
  report_name.column_list.append(ReportColumn(dt, "RecordId", FieldType.LONG))
246
- results: list[dict[str, Any]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
247
- page_limit, page_size, page_number)
290
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
291
+ page_limit, page_size, page_number)
248
292
  else:
249
293
  raise SapioException("Unrecognized report object.")
250
294
 
@@ -273,7 +317,8 @@ class RecordHandler:
273
317
  """
274
318
  return self.inst_man.add_new_records_of_type(num, wrapper_type)
275
319
 
276
- def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldMap]) -> list[WrappedType]:
320
+ def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
321
+ -> list[WrappedType]:
277
322
  """
278
323
  Shorthand for using the instance manager to add new models of the given type, and then initializing all those
279
324
  models with the given fields.
@@ -283,13 +328,14 @@ class RecordHandler:
283
328
  :return: The newly added record models with the provided fields set. The records will be in the same order as
284
329
  the fields in the fields list.
285
330
  """
331
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
286
332
  models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
287
333
  for model, field_list in zip(models, fields):
288
334
  model.set_field_values(field_list)
289
335
  return models
290
336
 
291
- def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
292
- secondary_identifiers: FieldMap | None = None) -> WrappedType:
337
+ def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
338
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType:
293
339
  """
294
340
  Find a unique record that matches the given field values. If no such records exist, add a record model to the
295
341
  cache with the identifying fields set to the desired values. This record will be created in the system when
@@ -312,6 +358,8 @@ class RecordHandler:
312
358
  if secondary_identifiers is None:
313
359
  secondary_identifiers = {}
314
360
 
361
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
362
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
315
363
  unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
316
364
  secondary_identifiers)
317
365
  # If a unique record matched the identifiers, return it.
@@ -338,7 +386,7 @@ class RecordHandler:
338
386
  dt: str = wrapper_type.get_wrapper_data_type_name()
339
387
  return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
340
388
 
341
- def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldMap]) \
389
+ def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
342
390
  -> list[WrappedType]:
343
391
  """
344
392
  Shorthand for creating new records via the data record manager with field data to initialize the records with
@@ -352,10 +400,12 @@ class RecordHandler:
352
400
  :return: The newly created record models.
353
401
  """
354
402
  dt: str = wrapper_type.get_wrapper_data_type_name()
403
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
355
404
  return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
356
405
 
357
- def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
358
- secondary_identifiers: FieldMap | None = None) -> WrappedType:
406
+ def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
407
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
408
+ -> WrappedType:
359
409
  """
360
410
  Find a unique record that matches the given field values. If no such records exist, create one with the
361
411
  identifying fields set to the desired values. If more than one record with the identifying values exists,
@@ -379,6 +429,8 @@ class RecordHandler:
379
429
  if secondary_identifiers is None:
380
430
  secondary_identifiers = {}
381
431
 
432
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
433
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
382
434
  unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
383
435
  secondary_identifiers)
384
436
  # If a unique record matched the identifiers, return it.
@@ -497,7 +549,7 @@ class RecordHandler:
497
549
 
498
550
  @staticmethod
499
551
  def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
500
- -> dict[WrappedType, list[RecordModel]]:
552
+ -> dict[WrappedType, RecordModel]:
501
553
  """
502
554
  Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
503
555
  If two records share the same child, an exception will be thrown. The children must already be loaded.
@@ -538,7 +590,7 @@ class RecordHandler:
538
590
  return by_children
539
591
 
540
592
  @staticmethod
541
- def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: str,
593
+ def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
542
594
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
543
595
  """
544
596
  Map a list of record models to their forward side link. The forward side link must already be loaded.
@@ -549,13 +601,14 @@ class RecordHandler:
549
601
  :return: A dict[ModelType, SlideLink]. If an input model doesn't have a forward side link of the given type,
550
602
  then it will map to None.
551
603
  """
604
+ field_name: str = AliasUtil.to_data_field_name(field_name)
552
605
  return_dict: dict[WrappedRecordModel, WrappedType] = {}
553
606
  for model in models:
554
607
  return_dict[model] = model.get_forward_side_link(field_name, side_link_type)
555
608
  return return_dict
556
609
 
557
610
  @staticmethod
558
- def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: str,
611
+ def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
559
612
  side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
560
613
  """
561
614
  Take a list of record models and map them by their forward side link. Essentially an inversion of
@@ -568,6 +621,7 @@ class RecordHandler:
568
621
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
569
622
  pointing to it, then it will not be in the resulting dictionary.
570
623
  """
624
+ field_name: str = AliasUtil.to_data_field_name(field_name)
571
625
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
572
626
  .map_to_forward_side_link(models, field_name, side_link_type)
573
627
  by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
@@ -578,7 +632,7 @@ class RecordHandler:
578
632
  return by_side_link
579
633
 
580
634
  @staticmethod
581
- def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: str,
635
+ def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
582
636
  side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
583
637
  """
584
638
  Take a list of record models and map them by their forward side link. Essentially an inversion of
@@ -591,6 +645,7 @@ class RecordHandler:
591
645
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
592
646
  pointing to it, then it will not be in the resulting dictionary.
593
647
  """
648
+ field_name: str = AliasUtil.to_data_field_name(field_name)
594
649
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
595
650
  .map_to_forward_side_link(models, field_name, side_link_type)
596
651
  by_side_link: dict[WrappedType, WrappedRecordModel] = {}
@@ -604,7 +659,7 @@ class RecordHandler:
604
659
  return by_side_link
605
660
 
606
661
  @staticmethod
607
- def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
662
+ def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
608
663
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
609
664
  """
610
665
  Map a list of record models to a list reverse side links of a given type. The reverse side links must already
@@ -617,13 +672,14 @@ class RecordHandler:
617
672
  :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
618
673
  then it will map to an empty list.
619
674
  """
675
+ field_name: str = AliasUtil.to_data_field_name(field_name)
620
676
  return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
621
677
  for model in models:
622
678
  return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
623
679
  return return_dict
624
680
 
625
681
  @staticmethod
626
- def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
682
+ def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
627
683
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
628
684
  """
629
685
  Map a list of record models to the reverse side link of a given type. If a given record has more than one
@@ -636,6 +692,7 @@ class RecordHandler:
636
692
  :return: A dict[ModelType, SideLink]. If an input model doesn't have reverse side links of the given type,
637
693
  then it will map to None.
638
694
  """
695
+ field_name: str = AliasUtil.to_data_field_name(field_name)
639
696
  return_dict: dict[WrappedRecordModel, WrappedType] = {}
640
697
  for model in models:
641
698
  links: list[WrappedType] = model.get_reverse_side_link(field_name, side_link_type)
@@ -646,7 +703,7 @@ class RecordHandler:
646
703
  return return_dict
647
704
 
648
705
  @staticmethod
649
- def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
706
+ def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
650
707
  side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
651
708
  """
652
709
  Take a list of record models and map them by their reverse side links. Essentially an inversion of
@@ -660,6 +717,7 @@ class RecordHandler:
660
717
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
661
718
  pointing to it, then it will not be in the resulting dictionary.
662
719
  """
720
+ field_name: str = AliasUtil.to_data_field_name(field_name)
663
721
  to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
664
722
  .map_to_reverse_side_links(models, field_name, side_link_type)
665
723
  by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
@@ -669,7 +727,7 @@ class RecordHandler:
669
727
  return by_side_links
670
728
 
671
729
  @staticmethod
672
- def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
730
+ def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
673
731
  side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
674
732
  """
675
733
  Take a list of record models and map them by their reverse side link. Essentially an inversion of
@@ -683,6 +741,7 @@ class RecordHandler:
683
741
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a reverse side link of the given type
684
742
  pointing to it, then it will not be in the resulting dictionary.
685
743
  """
744
+ field_name: str = AliasUtil.to_data_field_name(field_name)
686
745
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
687
746
  .map_to_reverse_side_link(models, field_name, side_link_type)
688
747
  by_side_link: dict[WrappedType, WrappedRecordModel] = {}
@@ -709,7 +768,8 @@ class RecordHandler:
709
768
  return ret_dict
710
769
 
711
770
  @staticmethod
712
- def map_by_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, list[SapioRecord]]:
771
+ def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
772
+ -> dict[FieldValue, list[SapioRecord]]:
713
773
  """
714
774
  Map the given records by one of their fields. If any two records share the same field value, they'll appear in
715
775
  the same value list.
@@ -718,14 +778,16 @@ class RecordHandler:
718
778
  :param field_name: The field name to map against.
719
779
  :return: A dict mapping field values to the records with that value.
720
780
  """
721
- ret_dict: dict[Any, list[SapioRecord]] = {}
781
+ field_name: str = AliasUtil.to_data_field_name(field_name)
782
+ ret_dict: dict[FieldValue, list[SapioRecord]] = {}
722
783
  for model in models:
723
- val: Any = model.get_field_value(field_name)
784
+ val: FieldValue = model.get_field_value(field_name)
724
785
  ret_dict.setdefault(val, []).append(model)
725
786
  return ret_dict
726
787
 
727
788
  @staticmethod
728
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, SapioRecord]:
789
+ def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
790
+ -> dict[FieldValue, SapioRecord]:
729
791
  """
730
792
  Uniquely map the given records by one of their fields. If any two records share the same field value, throws
731
793
  an exception.
@@ -734,16 +796,17 @@ class RecordHandler:
734
796
  :param field_name: The field name to map against.
735
797
  :return: A dict mapping field values to the record with that value.
736
798
  """
737
- ret_dict: dict[Any, SapioRecord] = {}
799
+ field_name: str = AliasUtil.to_data_field_name(field_name)
800
+ ret_dict: dict[FieldValue, SapioRecord] = {}
738
801
  for model in models:
739
- val: Any = model.get_field_value(field_name)
802
+ val: FieldValue = model.get_field_value(field_name)
740
803
  if val in ret_dict:
741
804
  raise SapioException(f"Value {val} encountered more than once in models list.")
742
805
  ret_dict.update({val: model})
743
806
  return ret_dict
744
807
 
745
808
  @staticmethod
746
- def sum_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
809
+ def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
747
810
  """
748
811
  Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
749
812
  If the field is an integer field, the value will be converted to a float.
@@ -752,13 +815,14 @@ class RecordHandler:
752
815
  :param field_name: The name of the numeric field to sum.
753
816
  :return: The sum of the field values for the collection of models.
754
817
  """
818
+ field_name: str = AliasUtil.to_data_field_name(field_name)
755
819
  field_sum: float = 0
756
820
  for model in models:
757
821
  field_sum += float(model.get_field_value(field_name))
758
822
  return field_sum
759
823
 
760
824
  @staticmethod
761
- def mean_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
825
+ def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
762
826
  """
763
827
  Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
764
828
  have a value. If the field is an integer field, the value will be converted to a float.
@@ -799,8 +863,8 @@ class RecordHandler:
799
863
  return oldest
800
864
 
801
865
  @staticmethod
802
- def values_to_field_maps(field_name: str, values: Iterable[Any], existing_fields: list[FieldMap] | None = None) \
803
- -> list[FieldMap]:
866
+ def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
867
+ existing_fields: list[FieldIdentifier] | None = None) -> list[FieldMap]:
804
868
  """
805
869
  Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
806
870
 
@@ -811,6 +875,8 @@ class RecordHandler:
811
875
  :return: A fields map list that contains the given values mapped by the given field name.
812
876
  """
813
877
  # Update the existing fields map list if one is given.
878
+ field_name: str = AliasUtil.to_data_field_name(field_name)
879
+ existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
814
880
  if existing_fields:
815
881
  values = list(values)
816
882
  # The number of new values must match the length of the existing fields list.
@@ -831,8 +897,6 @@ class RecordHandler:
831
897
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
832
898
  relationship path must already be loaded.
833
899
 
834
- Currently, the relationship path may only contain parent/child nodes.
835
-
836
900
  :param models: A list of record models.
837
901
  :param path: The relationship path to follow.
838
902
  :param wrapper_type: The record model wrapper to use.
@@ -843,15 +907,44 @@ class RecordHandler:
843
907
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
844
908
  path: list[RelationshipNode] = path.path
845
909
  for model in models:
846
- current: PyRecordModel = model if isinstance(model, PyRecordModel) else model.backing_model
910
+ current: PyRecordModel | None = model if isinstance(model, PyRecordModel) else model.backing_model
847
911
  for node in path:
848
- direction = node.direction
912
+ data_type: str = node.data_type_name
913
+ direction: RelationshipNodeType = node.direction
849
914
  if current is None:
850
915
  break
851
916
  if direction == RelationshipNodeType.CHILD:
852
- current = current.get_child_of_type(node.data_type_name)
917
+ current = current.get_child_of_type(data_type)
853
918
  elif direction == RelationshipNodeType.PARENT:
854
- current = current.get_parent_of_type(node.data_type_name)
919
+ current = current.get_parent_of_type(data_type)
920
+ elif direction == RelationshipNodeType.ANCESTOR:
921
+ ancestors: list[PyRecordModel] = list(self.an_man.get_ancestors_of_type(current, data_type))
922
+ if not ancestors:
923
+ current = None
924
+ elif len(ancestors) > 1:
925
+ raise SapioException(f"Hierarchy contains multiple ancestors of type {data_type}.")
926
+ else:
927
+ current = ancestors[0]
928
+ elif direction == RelationshipNodeType.DESCENDANT:
929
+ descendants: list[PyRecordModel] = list(self.an_man.get_descendant_of_type(current, data_type))
930
+ if not descendants:
931
+ current = None
932
+ elif len(descendants) > 1:
933
+ raise SapioException(f"Hierarchy contains multiple descendants of type {data_type}.")
934
+ else:
935
+ current = descendants[0]
936
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
937
+ current = current.get_forward_side_link(node.data_field_name)
938
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
939
+ field_name: str = node.data_field_name
940
+ reverse_links: list[PyRecordModel] = current.get_reverse_side_link(field_name, data_type)
941
+ if not reverse_links:
942
+ current = None
943
+ elif len(reverse_links) > 1:
944
+ raise SapioException(f"Hierarchy contains multiple reverse links of type {data_type} on field "
945
+ f"{field_name}.")
946
+ else:
947
+ current = reverse_links[0]
855
948
  else:
856
949
  raise SapioException("Unsupported path direction.")
857
950
  ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
@@ -864,8 +957,6 @@ class RecordHandler:
864
957
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
865
958
  relationship path must already be loaded.
866
959
 
867
- Currently, the relationship path may only contain parent/child nodes.
868
-
869
960
  :param models: A list of record models.
870
961
  :param path: The relationship path to follow.
871
962
  :param wrapper_type: The record model wrapper to use.
@@ -880,14 +971,23 @@ class RecordHandler:
880
971
  next_search: set[PyRecordModel] = set()
881
972
  # Exhaust the records at each step in the path, then use those records for the next step.
882
973
  for node in path:
883
- direction = node.direction
974
+ data_type: str = node.data_type_name
975
+ direction: RelationshipNodeType = node.direction
884
976
  if len(current_search) == 0:
885
977
  break
886
978
  for search in current_search:
887
979
  if direction == RelationshipNodeType.CHILD:
888
- next_search.update(search.get_children_of_type(node.data_type_name))
980
+ next_search.update(search.get_children_of_type(data_type))
889
981
  elif direction == RelationshipNodeType.PARENT:
890
- next_search.update(search.get_parents_of_type(node.data_type_name))
982
+ next_search.update(search.get_parents_of_type(data_type))
983
+ elif direction == RelationshipNodeType.ANCESTOR:
984
+ next_search.update(self.an_man.get_ancestors_of_type(search, data_type))
985
+ elif direction == RelationshipNodeType.DESCENDANT:
986
+ next_search.update(self.an_man.get_descendant_of_type(search, data_type))
987
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
988
+ next_search.add(search.get_forward_side_link(node.data_field_name))
989
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
990
+ next_search.update(search.get_reverse_side_link(node.data_field_name, data_type))
891
991
  else:
892
992
  raise SapioException("Unsupported path direction.")
893
993
  current_search = next_search
@@ -908,8 +1008,6 @@ class RecordHandler:
908
1008
  relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
909
1009
  together into a single sample).
910
1010
 
911
- Currently, the relationship path may only contain parent/child nodes.
912
-
913
1011
  :param models: A list of record models.
914
1012
  :param path: The relationship path to follow.
915
1013
  :param wrapper_type: The record model wrapper to use.
@@ -922,20 +1020,29 @@ class RecordHandler:
922
1020
  for model in models:
923
1021
  current: list[PyRecordModel] = [model if isinstance(model, PyRecordModel) else model.backing_model]
924
1022
  for node in path:
925
- direction = node.direction
1023
+ data_type: str = node.data_type_name
1024
+ direction: RelationshipNodeType = node.direction
926
1025
  if len(current) == 0:
927
1026
  break
928
1027
  if direction == RelationshipNodeType.CHILD:
929
- current = current[0].get_children_of_type(node.data_type_name)
1028
+ current = current[0].get_children_of_type(data_type)
930
1029
  elif direction == RelationshipNodeType.PARENT:
931
- current = current[0].get_parents_of_type(node.data_type_name)
1030
+ current = current[0].get_parents_of_type(data_type)
1031
+ elif direction == RelationshipNodeType.ANCESTOR:
1032
+ current = list(self.an_man.get_ancestors_of_type(current[0], data_type))
1033
+ elif direction == RelationshipNodeType.DESCENDANT:
1034
+ current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1035
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1036
+ current = [current[0].get_forward_side_link(node.data_field_name)]
1037
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1038
+ current = current[0].get_reverse_side_link(node.data_field_name, data_type)
932
1039
  else:
933
1040
  raise SapioException("Unsupported path direction.")
934
1041
  ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
935
1042
  return ret_dict
936
1043
 
937
- def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
938
- secondary_identifiers: FieldMap | None = None) -> WrappedType | None:
1044
+ def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: FieldValue,
1045
+ secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType | None:
939
1046
  """
940
1047
  Find a record from the system that matches the given field values. The primary identifier and value is used
941
1048
  to query for the record, then the secondary identifiers may be optionally provided to further filter the
@@ -959,3 +1066,18 @@ class RecordHandler:
959
1066
  f"encountered in system that matches all provided identifiers.")
960
1067
  unique_record = result
961
1068
  return unique_record
1069
+
1070
+ @staticmethod
1071
+ def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1072
+ """
1073
+ Throw an exception if the data type of the given records and wrapper don't match.
1074
+ """
1075
+ model_type: str = wrapper_type.get_wrapper_data_type_name()
1076
+ for record in records:
1077
+ record_type: str = record.data_type_name
1078
+ # Account for ELN data type records.
1079
+ if ElnBaseDataType.is_eln_type(record_type):
1080
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1081
+ if record_type != model_type:
1082
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1083
+ f"of type {model_type}")